Initial commit
This commit is contained in:
30
app/Console/Commands/.php-cs-fixer.dist.php
Normal file
30
app/Console/Commands/.php-cs-fixer.dist.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PhpCsFixer\Config;
|
||||
use PhpCsFixer\Finder;
|
||||
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
|
||||
|
||||
return (new Config())
|
||||
->setParallelConfig(ParallelConfigFactory::detect()) // @TODO 4.0 no need to call this manually
|
||||
->setRiskyAllowed(false)
|
||||
->setRules([
|
||||
'@auto' => true
|
||||
])
|
||||
// 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config
|
||||
->setFinder(
|
||||
(new Finder())
|
||||
// 💡 root folder to check
|
||||
->in(__DIR__)
|
||||
// 💡 additional files, eg bin entry file
|
||||
// ->append([__DIR__.'/bin-entry-file'])
|
||||
// 💡 folders to exclude, if any
|
||||
// ->exclude([/* ... */])
|
||||
// 💡 path patterns to exclude, if any
|
||||
// ->notPath([/* ... */])
|
||||
// 💡 extra configs
|
||||
// ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode
|
||||
// ->ignoreVCS(true) // true by default
|
||||
)
|
||||
;
|
||||
57
app/Console/Commands/AddLoveReactionsToTransactions.php
Normal file
57
app/Console/Commands/AddLoveReactionsToTransactions.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionType;
|
||||
use Cog\Laravel\Love\ReactionType\Models\ReactionType as LoveReactionType;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AddLoveReactionsToTransactions extends Command
|
||||
{
|
||||
protected $signature = 'love:add-reactions-to-transactions';
|
||||
protected $description = 'Add Love Reaction to each from_account and to_account accountable with the transaction_type name as the reaction type';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$transactions = Transaction::with(['accountFrom.accountable', 'accountTo.accountable', 'transactionType'])->get();
|
||||
$count = 0;
|
||||
|
||||
$this->info('Adding love Reactions to each transaction. Please wait, this can take a while...');
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$reactionTypeName = $transaction->transactionType->name ?? null;
|
||||
if (!$reactionTypeName) {
|
||||
Log::warning("Transaction {$transaction->id} has no transaction type name. Type is set to Work as a fallback");
|
||||
$reactionTypeName = 'Work';
|
||||
}
|
||||
|
||||
// Check if reaction type exists in love_reaction_types
|
||||
if (!LoveReactionType::where('name', $reactionTypeName)->exists()) {
|
||||
Log::warning("ReactionType '{$reactionTypeName}' does not exist for transaction {$transaction->id}.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$fromAccountable = $transaction->accountFrom->accountable ?? null;
|
||||
$toAccountable = $transaction->accountTo->accountable ?? null;
|
||||
Log::info("Transaction {$transaction->id}: fromAccountable=" . ($fromAccountable ? get_class($fromAccountable) . ':' . $fromAccountable->id : 'null') . ", toAccountable=" . ($toAccountable ? get_class($toAccountable) . ':' . $toAccountable->id : 'null'));
|
||||
|
||||
try {
|
||||
if ($fromAccountable && $toAccountable) {
|
||||
Log::info("Transaction {$transaction->id}: Adding reaction '{$reactionTypeName}' from {$fromAccountable->id} to {$toAccountable->id}.");
|
||||
$fromAccountable->viaLoveReacter()->reactTo($toAccountable, $reactionTypeName);
|
||||
Log::info("Transaction {$transaction->id}: Adding reaction '{$reactionTypeName}' from {$toAccountable->id} to {$fromAccountable->id}.");
|
||||
$toAccountable->viaLoveReacter()->reactTo($fromAccountable, $reactionTypeName);
|
||||
$count++;
|
||||
} else {
|
||||
Log::warning("Transaction {$transaction->id}: Missing fromAccountable or toAccountable.");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Error adding reaction for transaction {$transaction->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Added reactions for {$count} transactions.");
|
||||
}
|
||||
}
|
||||
415
app/Console/Commands/BackupPosts.php
Normal file
415
app/Console/Commands/BackupPosts.php
Normal file
@@ -0,0 +1,415 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Locations\CityLocale;
|
||||
use App\Models\Locations\CountryLocale;
|
||||
use App\Models\Locations\DistrictLocale;
|
||||
use App\Models\Locations\DivisionLocale;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTranslation;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use ZipArchive;
|
||||
|
||||
class BackupPosts extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'posts:backup
|
||||
{--output= : Output file path (default: backups/posts/posts_backup_YYYYMMDD_HHMMSS.zip)}
|
||||
{--post-ids= : Comma-separated list of post IDs to backup (e.g., --post-ids=29,30,405,502)}
|
||||
{--exclude-media : Exclude media files from the backup (creates smaller JSON-only backup)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Backup posts, post_translations, meetings, and media to a ZIP archive for restoration on another database';
|
||||
|
||||
/**
|
||||
* The console command help text.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $help = <<<'HELP'
|
||||
Examples:
|
||||
<info>php artisan posts:backup</info>
|
||||
Backup all posts with media to the default location (backups/posts/posts_YYYYMMDD_HHMMSS.zip)
|
||||
|
||||
<info>php artisan posts:backup --output=my_backup.zip</info>
|
||||
Backup all posts with media to a custom file path
|
||||
|
||||
<info>php artisan posts:backup --post-ids=29,30,405,502</info>
|
||||
Backup only specific posts by their IDs
|
||||
|
||||
<info>php artisan posts:backup --exclude-media</info>
|
||||
Backup posts without media files (JSON-only, smaller file size)
|
||||
|
||||
<info>php artisan posts:backup --post-ids=29,30 --output=backups/selected_posts.zip</info>
|
||||
Backup specific posts to a custom file path
|
||||
HELP;
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Starting posts backup...');
|
||||
|
||||
$excludeMedia = $this->option('exclude-media');
|
||||
|
||||
// Determine output path
|
||||
$outputPath = $this->option('output');
|
||||
$extension = $excludeMedia ? 'json' : 'zip';
|
||||
|
||||
if (!$outputPath) {
|
||||
$backupDir = base_path('backups/posts');
|
||||
if (!File::isDirectory($backupDir)) {
|
||||
File::makeDirectory($backupDir, 0755, true);
|
||||
}
|
||||
$timestamp = now()->format('Ymd_His');
|
||||
$outputPath = "{$backupDir}/posts_backup_{$timestamp}.{$extension}";
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
$directory = dirname($outputPath);
|
||||
if (!File::isDirectory($directory)) {
|
||||
File::makeDirectory($directory, 0755, true);
|
||||
}
|
||||
|
||||
// Build posts query (only non-deleted posts)
|
||||
$query = Post::query();
|
||||
|
||||
// Filter by specific post IDs if provided
|
||||
$postIdsOption = $this->option('post-ids');
|
||||
if ($postIdsOption) {
|
||||
$postIds = array_map('trim', explode(',', $postIdsOption));
|
||||
$postIds = array_filter($postIds, fn($id) => is_numeric($id));
|
||||
|
||||
if (empty($postIds)) {
|
||||
$this->error('Invalid post IDs provided. Use comma-separated numeric IDs (e.g., --post-ids=29,30,405)');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$query->whereIn('id', $postIds);
|
||||
$this->info('Filtering by post IDs: ' . implode(', ', $postIds));
|
||||
}
|
||||
|
||||
$totalPosts = $query->count();
|
||||
|
||||
if ($totalPosts === 0) {
|
||||
$this->warn('No posts found to backup.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$totalPosts} posts to backup");
|
||||
|
||||
// Build category type lookup (id => type)
|
||||
$categoryTypes = Category::pluck('type', 'id')->toArray();
|
||||
|
||||
// Track counts and media files
|
||||
$counts = ['posts' => 0, 'post_translations' => 0, 'meetings' => 0, 'media_files' => 0];
|
||||
$mediaFiles = [];
|
||||
|
||||
// Write posts as JSON incrementally to a temp file to avoid holding everything in memory
|
||||
$tempJsonPath = storage_path('app/temp/' . uniqid('backup_json_') . '.json');
|
||||
$tempDir = dirname($tempJsonPath);
|
||||
if (!File::isDirectory($tempDir)) {
|
||||
File::makeDirectory($tempDir, 0755, true);
|
||||
}
|
||||
|
||||
$jsonHandle = fopen($tempJsonPath, 'w');
|
||||
fwrite($jsonHandle, '{"meta":"__PLACEHOLDER__","posts":[');
|
||||
|
||||
$bar = $this->output->createProgressBar($totalPosts);
|
||||
$bar->start();
|
||||
|
||||
$isFirst = true;
|
||||
|
||||
$query->with(['translations', 'meeting.location', 'media'])
|
||||
->chunk(100, function ($posts) use (
|
||||
$categoryTypes, $excludeMedia, $jsonHandle,
|
||||
&$isFirst, &$counts, &$mediaFiles, $bar
|
||||
) {
|
||||
foreach ($posts as $post) {
|
||||
$categoryType = $categoryTypes[$post->category_id] ?? null;
|
||||
|
||||
$postData = [
|
||||
'category_type' => $categoryType,
|
||||
'love_reactant_id' => $post->love_reactant_id,
|
||||
'author_id' => $post->author_id,
|
||||
'author_model' => $post->author_model,
|
||||
'created_at' => $this->formatDate($post->created_at),
|
||||
'updated_at' => $this->formatDate($post->updated_at),
|
||||
'translations' => [],
|
||||
'meeting' => null,
|
||||
'media' => null,
|
||||
];
|
||||
|
||||
foreach ($post->translations as $translation) {
|
||||
$postData['translations'][] = [
|
||||
'locale' => $translation->locale,
|
||||
'slug' => $translation->slug,
|
||||
'title' => $translation->title,
|
||||
'excerpt' => $translation->excerpt,
|
||||
'content' => $translation->content,
|
||||
'status' => $translation->status,
|
||||
'updated_by_user_id' => $translation->updated_by_user_id,
|
||||
'from' => $this->formatDate($translation->from),
|
||||
'till' => $this->formatDate($translation->till),
|
||||
'created_at' => $this->formatDate($translation->created_at),
|
||||
'updated_at' => $this->formatDate($translation->updated_at),
|
||||
];
|
||||
$counts['post_translations']++;
|
||||
}
|
||||
|
||||
if ($post->meeting) {
|
||||
$meeting = $post->meeting;
|
||||
$postData['meeting'] = [
|
||||
'meetingable_type' => $meeting->meetingable_type,
|
||||
'meetingable_name' => $meeting->meetingable?->name,
|
||||
'venue' => $meeting->venue,
|
||||
'address' => $meeting->address,
|
||||
'price' => $meeting->price,
|
||||
'based_on_quantity' => $meeting->based_on_quantity,
|
||||
'transaction_type_id' => $meeting->transaction_type_id,
|
||||
'status' => $meeting->status,
|
||||
'from' => $this->formatDate($meeting->from),
|
||||
'till' => $this->formatDate($meeting->till),
|
||||
'created_at' => $this->formatDate($meeting->created_at),
|
||||
'updated_at' => $this->formatDate($meeting->updated_at),
|
||||
'location' => $this->getLocationNames($meeting->location),
|
||||
];
|
||||
$counts['meetings']++;
|
||||
}
|
||||
|
||||
if (!$excludeMedia) {
|
||||
$media = $post->getFirstMedia('posts');
|
||||
if ($media) {
|
||||
$originalPath = $media->getPath();
|
||||
if (File::exists($originalPath)) {
|
||||
$archivePath = "media/{$post->id}/{$media->file_name}";
|
||||
$postData['media'] = [
|
||||
'name' => $media->name,
|
||||
'file_name' => $media->file_name,
|
||||
'mime_type' => $media->mime_type,
|
||||
'size' => $media->size,
|
||||
'collection_name' => $media->collection_name,
|
||||
'custom_properties' => $media->custom_properties,
|
||||
'archive_path' => $archivePath,
|
||||
];
|
||||
$mediaFiles[] = [
|
||||
'source' => $originalPath,
|
||||
'archive_path' => $archivePath,
|
||||
];
|
||||
$counts['media_files']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isFirst) {
|
||||
fwrite($jsonHandle, ',');
|
||||
}
|
||||
fwrite($jsonHandle, json_encode($postData, JSON_UNESCAPED_UNICODE));
|
||||
$isFirst = false;
|
||||
$counts['posts']++;
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
|
||||
// Close JSON array
|
||||
fwrite($jsonHandle, ']}');
|
||||
fclose($jsonHandle);
|
||||
|
||||
// Build meta JSON
|
||||
$meta = json_encode([
|
||||
'version' => '2.0',
|
||||
'created_at' => now()->toIso8601String(),
|
||||
'source_database' => config('database.connections.mysql.database'),
|
||||
'includes_media' => !$excludeMedia,
|
||||
'counts' => $counts,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
// Replace the placeholder in the temp JSON file without reading it all into memory
|
||||
$finalJsonPath = $tempJsonPath . '.final';
|
||||
$inHandle = fopen($tempJsonPath, 'r');
|
||||
$outHandle = fopen($finalJsonPath, 'w');
|
||||
|
||||
// Read the placeholder prefix, replace it, then stream the rest
|
||||
$prefix = fread($inHandle, strlen('{"meta":"__PLACEHOLDER__"'));
|
||||
fwrite($outHandle, '{"meta":' . $meta);
|
||||
|
||||
// Stream the rest of the file in small chunks
|
||||
while (!feof($inHandle)) {
|
||||
fwrite($outHandle, fread($inHandle, 8192));
|
||||
}
|
||||
|
||||
fclose($inHandle);
|
||||
fclose($outHandle);
|
||||
@unlink($tempJsonPath);
|
||||
rename($finalJsonPath, $tempJsonPath);
|
||||
|
||||
if ($excludeMedia) {
|
||||
// Move temp JSON as the final output
|
||||
File::move($tempJsonPath, $outputPath);
|
||||
} else {
|
||||
// Create ZIP archive with JSON and media files
|
||||
$this->info('Creating ZIP archive with media files...');
|
||||
|
||||
if (!class_exists('ZipArchive')) {
|
||||
@unlink($tempJsonPath);
|
||||
$this->error('ZipArchive extension is not available. Install php-zip extension or use --exclude-media flag.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
@unlink($tempJsonPath);
|
||||
$this->error("Failed to create ZIP archive: {$outputPath}");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Add backup.json to archive
|
||||
$zip->addFile($tempJsonPath, 'backup.json');
|
||||
|
||||
// Add media files to archive
|
||||
$mediaBar = $this->output->createProgressBar(count($mediaFiles));
|
||||
$mediaBar->start();
|
||||
|
||||
foreach ($mediaFiles as $mediaFile) {
|
||||
if (File::exists($mediaFile['source'])) {
|
||||
$zip->addFile($mediaFile['source'], $mediaFile['archive_path']);
|
||||
}
|
||||
$mediaBar->advance();
|
||||
}
|
||||
|
||||
$mediaBar->finish();
|
||||
$this->newLine();
|
||||
|
||||
$zip->close();
|
||||
@unlink($tempJsonPath);
|
||||
}
|
||||
|
||||
$fileSize = $this->formatBytes(File::size($outputPath));
|
||||
|
||||
$this->info("Backup completed successfully!");
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Posts', $counts['posts']],
|
||||
['Translations', $counts['post_translations']],
|
||||
['Meetings', $counts['meetings']],
|
||||
['Media Files', $counts['media_files']],
|
||||
['File Size', $fileSize],
|
||||
['Output File', $outputPath],
|
||||
]
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable format.
|
||||
*/
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$i = 0;
|
||||
while ($bytes >= 1024 && $i < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
$i++;
|
||||
}
|
||||
return round($bytes, 2) . ' ' . $units[$i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely format a date value to ISO8601 string.
|
||||
*/
|
||||
private function formatDate($value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($value instanceof \Carbon\Carbon || $value instanceof \DateTime) {
|
||||
return $value->format('c');
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get location names in the app's base locale for backup.
|
||||
* Names are used for lookup on restore instead of IDs.
|
||||
*/
|
||||
private function getLocationNames($location): ?array
|
||||
{
|
||||
if (!$location) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseLocale = config('app.locale');
|
||||
|
||||
// Get country name
|
||||
$countryName = null;
|
||||
if ($location->country_id) {
|
||||
$countryLocale = CountryLocale::withoutGlobalScopes()
|
||||
->where('country_id', $location->country_id)
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$countryName = $countryLocale?->name;
|
||||
}
|
||||
|
||||
// Get division name
|
||||
$divisionName = null;
|
||||
if ($location->division_id) {
|
||||
$divisionLocale = DivisionLocale::withoutGlobalScopes()
|
||||
->where('division_id', $location->division_id)
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$divisionName = $divisionLocale?->name;
|
||||
}
|
||||
|
||||
// Get city name
|
||||
$cityName = null;
|
||||
if ($location->city_id) {
|
||||
$cityLocale = CityLocale::withoutGlobalScopes()
|
||||
->where('city_id', $location->city_id)
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$cityName = $cityLocale?->name;
|
||||
}
|
||||
|
||||
// Get district name
|
||||
$districtName = null;
|
||||
if ($location->district_id) {
|
||||
$districtLocale = DistrictLocale::withoutGlobalScopes()
|
||||
->where('district_id', $location->district_id)
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$districtName = $districtLocale?->name;
|
||||
}
|
||||
|
||||
return [
|
||||
'country_name' => $countryName,
|
||||
'division_name' => $divisionName,
|
||||
'city_name' => $cityName,
|
||||
'district_name' => $districtName,
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/CheckTranslations.php
Normal file
40
app/Console/Commands/CheckTranslations.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class CheckTranslations extends Command
|
||||
{
|
||||
protected $signature = 'translations:check';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$baseLanguage = config('app.fallback_locale');
|
||||
$languages = config('app.locales');
|
||||
|
||||
$baseFiles = File::files(resource_path("lang/{$baseLanguage}"));
|
||||
|
||||
foreach ($baseFiles as $file) {
|
||||
$filename = $file->getFilename();
|
||||
$baseTranslations = require $file->getPathname();
|
||||
|
||||
foreach ($languages as $language) {
|
||||
$path = resource_path("lang/{$language}/{$filename}");
|
||||
|
||||
if (!File::exists($path)) {
|
||||
$this->error("Missing file: {$language}/{$filename}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$translations = require $path;
|
||||
$missingKeys = array_diff_key($baseTranslations, $translations);
|
||||
|
||||
if (!empty($missingKeys)) {
|
||||
$this->warn("Missing keys in {$language}/{$filename}: " . implode(', ', array_keys($missingKeys)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
96
app/Console/Commands/CleanCyclosProfiles.php
Normal file
96
app/Console/Commands/CleanCyclosProfiles.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CleanCyclosProfiles extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'profiles:clean-about';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Clean empty Cyclos migration paragraph markup from profile about fields';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Starting cleanup of Cyclos migration empty about fields...');
|
||||
$this->newLine();
|
||||
|
||||
$totalCleaned = 0;
|
||||
|
||||
// Clean Users
|
||||
$usersCleaned = $this->cleanProfileAbout(User::class, 'Users');
|
||||
$totalCleaned += $usersCleaned;
|
||||
|
||||
// Clean Organizations
|
||||
$organizationsCleaned = $this->cleanProfileAbout(Organization::class, 'Organizations');
|
||||
$totalCleaned += $organizationsCleaned;
|
||||
|
||||
// Clean Banks
|
||||
$banksCleaned = $this->cleanProfileAbout(Bank::class, 'Banks');
|
||||
$totalCleaned += $banksCleaned;
|
||||
|
||||
// Clean Admins
|
||||
$adminsCleaned = $this->cleanProfileAbout(Admin::class, 'Admins');
|
||||
$totalCleaned += $adminsCleaned;
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Cleanup complete! Total profiles cleaned: {$totalCleaned}");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the about field for a specific model type.
|
||||
*
|
||||
* @param string $modelClass
|
||||
* @param string $displayName
|
||||
* @return int
|
||||
*/
|
||||
private function cleanProfileAbout(string $modelClass, string $displayName): int
|
||||
{
|
||||
// Find all profiles where about field contains only the empty paragraph or single quote
|
||||
$profiles = $modelClass::where('about', '<p></p>')
|
||||
->orWhere('about', '<p> </p>')
|
||||
->orWhere('about', '<p> </p>')
|
||||
->orWhere('about', '"')
|
||||
->get();
|
||||
|
||||
$count = $profiles->count();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->line("✓ {$displayName}: No profiles to clean");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$bar = $this->output->createProgressBar($count);
|
||||
$bar->setFormat(" {$displayName}: [%bar%] %current%/%max% (%percent:3s%%)");
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$profile->about = null;
|
||||
$profile->save();
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
103
app/Console/Commands/CleanCyclosSkills.php
Normal file
103
app/Console/Commands/CleanCyclosSkills.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CleanCyclosSkills extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'profiles:clean-cyclos-skills';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Clean trailing pipe symbols from cyclos_skills field after Cyclos migration';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Starting cleanup of Cyclos migration trailing pipes in cyclos_skills...');
|
||||
$this->newLine();
|
||||
|
||||
$totalCleaned = 0;
|
||||
|
||||
// Clean Users
|
||||
$usersCleaned = $this->cleanProfileSkills(User::class, 'Users');
|
||||
$totalCleaned += $usersCleaned;
|
||||
|
||||
// Clean Organizations
|
||||
$organizationsCleaned = $this->cleanProfileSkills(Organization::class, 'Organizations');
|
||||
$totalCleaned += $organizationsCleaned;
|
||||
|
||||
// Clean Banks
|
||||
$banksCleaned = $this->cleanProfileSkills(Bank::class, 'Banks');
|
||||
$totalCleaned += $banksCleaned;
|
||||
|
||||
// Note: Admins table does not have cyclos_skills column
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Cleanup complete! Total profiles cleaned: {$totalCleaned}");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the cyclos_skills field for a specific model type.
|
||||
*
|
||||
* @param string $modelClass
|
||||
* @param string $displayName
|
||||
* @return int
|
||||
*/
|
||||
private function cleanProfileSkills(string $modelClass, string $displayName): int
|
||||
{
|
||||
// Find all profiles where cyclos_skills field contains trailing pipes and spaces
|
||||
$profiles = $modelClass::whereNotNull('cyclos_skills')
|
||||
->where('cyclos_skills', 'like', '%|%')
|
||||
->get();
|
||||
|
||||
$cleanedCount = 0;
|
||||
|
||||
if ($profiles->isEmpty()) {
|
||||
$this->line("✓ {$displayName}: No profiles to clean");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$bar = $this->output->createProgressBar($profiles->count());
|
||||
$bar->setFormat(" {$displayName}: [%bar%] %current%/%max% (%percent:3s%%)");
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$original = $profile->cyclos_skills;
|
||||
|
||||
// Remove trailing pipes and spaces using regex
|
||||
// This matches: space, pipe, space, pipe... at the end of the string
|
||||
$cleaned = preg_replace('/(\s*\|\s*)+$/', '', $original);
|
||||
|
||||
// Only update if something changed
|
||||
if ($cleaned !== $original) {
|
||||
$profile->cyclos_skills = $cleaned;
|
||||
$profile->save();
|
||||
$cleanedCount++;
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
|
||||
return $cleanedCount;
|
||||
}
|
||||
}
|
||||
184
app/Console/Commands/CleanupIpAddresses.php
Normal file
184
app/Console/Commands/CleanupIpAddresses.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class CleanupIpAddresses extends Command
|
||||
{
|
||||
protected $signature = 'ip:cleanup {--dry-run : Show what would be cleaned without actually deleting}';
|
||||
protected $description;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->description = 'Anonymize IP addresses older than ' .
|
||||
timebank_config('ip_retention.retention_days') . ' days for GDPR compliance';
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$retentionDays = timebank_config('ip_retention.retention_days', 180);
|
||||
$cutoffDate = now()->subDays($retentionDays);
|
||||
$isDryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('Starting IP address cleanup...');
|
||||
$this->info('Retention period: ' . $retentionDays . ' days');
|
||||
$this->info('Cutoff date: ' . $cutoffDate->toDateTimeString());
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
|
||||
$totalAnonymized = 0;
|
||||
|
||||
// Cleanup profile tables (users, organizations, banks, admins)
|
||||
$profileModels = [
|
||||
'users' => User::class,
|
||||
'organizations' => Organization::class,
|
||||
'banks' => Bank::class,
|
||||
'admins' => Admin::class,
|
||||
];
|
||||
|
||||
foreach ($profileModels as $tableName => $modelClass) {
|
||||
$count = $this->cleanupProfileTable($tableName, $modelClass, $cutoffDate, $isDryRun);
|
||||
$totalAnonymized += $count;
|
||||
}
|
||||
|
||||
// Cleanup activity log IP addresses
|
||||
$activityLogCount = $this->cleanupActivityLog($cutoffDate, $isDryRun);
|
||||
$totalAnonymized += $activityLogCount;
|
||||
|
||||
$action = $isDryRun ? 'Would anonymize' : 'Anonymized';
|
||||
$this->info("✓ {$action} {$totalAnonymized} IP address records in total.");
|
||||
|
||||
// Log the cleanup action
|
||||
if (!$isDryRun) {
|
||||
Log::info('IP address cleanup completed', [
|
||||
'retention_days' => $retentionDays,
|
||||
'cutoff_date' => $cutoffDate->toDateTimeString(),
|
||||
'total_anonymized' => $totalAnonymized,
|
||||
]);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup IP addresses from profile tables
|
||||
*
|
||||
* @param string $tableName
|
||||
* @param string $modelClass
|
||||
* @param \Carbon\Carbon $cutoffDate
|
||||
* @param bool $isDryRun
|
||||
* @return int Number of records anonymized
|
||||
*/
|
||||
protected function cleanupProfileTable(string $tableName, string $modelClass, $cutoffDate, bool $isDryRun): int
|
||||
{
|
||||
$this->line("\nProcessing {$tableName} table...");
|
||||
|
||||
// Find profiles with last_login_ip that should be anonymized
|
||||
$query = $modelClass::whereNotNull('last_login_ip')
|
||||
->where('last_login_ip', '!=', '')
|
||||
->where(function ($q) use ($cutoffDate) {
|
||||
// Anonymize if last_login_at is older than cutoff date
|
||||
$q->where('last_login_at', '<', $cutoffDate)
|
||||
// Or if last_login_at is null (should not happen, but handle it)
|
||||
->orWhereNull('last_login_at');
|
||||
});
|
||||
|
||||
$count = $query->count();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->line(" No IP addresses to anonymize in {$tableName}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn(" Would anonymize {$count} IP addresses in {$tableName}");
|
||||
|
||||
// Show some examples in dry run
|
||||
$examples = $query->take(3)->get(['id', 'name', 'last_login_ip', 'last_login_at']);
|
||||
if ($examples->isNotEmpty()) {
|
||||
$this->line(" Examples:");
|
||||
foreach ($examples as $example) {
|
||||
$loginDate = 'never';
|
||||
if ($example->last_login_at) {
|
||||
$loginDate = is_string($example->last_login_at)
|
||||
? $example->last_login_at
|
||||
: $example->last_login_at->toDateString();
|
||||
}
|
||||
$this->line(" - ID {$example->id} ({$example->name}): {$example->last_login_ip} (last login: {$loginDate})");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Anonymize by setting to null
|
||||
$updated = $query->update(['last_login_ip' => null]);
|
||||
$this->info(" ✓ Anonymized {$updated} IP addresses in {$tableName}");
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup IP addresses from activity log
|
||||
*
|
||||
* @param \Carbon\Carbon $cutoffDate
|
||||
* @param bool $isDryRun
|
||||
* @return int Number of records anonymized
|
||||
*/
|
||||
protected function cleanupActivityLog($cutoffDate, bool $isDryRun): int
|
||||
{
|
||||
$this->line("\nProcessing activity_log table...");
|
||||
|
||||
// Find activity logs with IP addresses older than cutoff date
|
||||
$query = Activity::whereNotNull('properties->ip')
|
||||
->where('created_at', '<', $cutoffDate);
|
||||
|
||||
$count = $query->count();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->line(" No IP addresses to anonymize in activity_log");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn(" Would anonymize {$count} IP addresses in activity_log");
|
||||
|
||||
// Show some examples in dry run
|
||||
$examples = $query->take(3)->get(['id', 'log_name', 'properties', 'created_at']);
|
||||
if ($examples->isNotEmpty()) {
|
||||
$this->line(" Examples:");
|
||||
foreach ($examples as $example) {
|
||||
$ip = $example->properties['ip'] ?? 'N/A';
|
||||
$this->line(" - ID {$example->id} ({$example->log_name}): {$ip} (date: {$example->created_at->toDateString()})");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Anonymize by removing IP from properties JSON
|
||||
$activities = $query->get();
|
||||
$updated = 0;
|
||||
|
||||
foreach ($activities as $activity) {
|
||||
$properties = $activity->properties;
|
||||
if (isset($properties['ip'])) {
|
||||
unset($properties['ip']);
|
||||
$activity->properties = $properties;
|
||||
$activity->save();
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(" ✓ Anonymized {$updated} IP addresses in activity_log");
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
46
app/Console/Commands/CleanupOfflineUsers.php
Normal file
46
app/Console/Commands/CleanupOfflineUsers.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\PresenceService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class CleanupOfflineUsers extends Command
|
||||
{
|
||||
protected $signature = 'presence:cleanup-offline {--minutes=5}';
|
||||
protected $description = 'Mark inactive users as offline';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$minutes = $this->option('minutes');
|
||||
$presenceService = app(PresenceService::class);
|
||||
|
||||
// Find users who haven't been active
|
||||
$inactiveUsers = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('created_at', '<', now()->subMinutes($minutes))
|
||||
->where('properties->status', '!=', 'offline')
|
||||
->with('subject')
|
||||
->get()
|
||||
->unique('subject_id');
|
||||
|
||||
$count = 0;
|
||||
foreach ($inactiveUsers as $activity) {
|
||||
if ($activity->subject) {
|
||||
$guard = $activity->properties['guard'] ?? 'web';
|
||||
$presenceService->setUserOffline($activity->subject, $guard);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all presence caches
|
||||
$guards = ['web', 'admin']; // Add your guards here
|
||||
foreach ($guards as $guard) {
|
||||
Cache::forget("online_users_{$guard}_5");
|
||||
}
|
||||
|
||||
$this->info("Marked {$count} inactive users as offline.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
47
app/Console/Commands/CleanupPresenceData.php
Normal file
47
app/Console/Commands/CleanupPresenceData.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
// 6. Console Command for Cleanup
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class CleanupPresenceData extends Command
|
||||
{
|
||||
protected $signature = 'presence:cleanup';
|
||||
protected $description;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->description = 'Clean up old presence activity logs, keeping last ' .
|
||||
timebank_config('presence_settings.keep_last_presence_updates') . ' per profile';
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
// Get all presence activities grouped by causer (profile)
|
||||
$presenceActivities = Activity::where('log_name', 'presence_update')
|
||||
->whereNotNull('causer_id')
|
||||
->whereNotNull('causer_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->groupBy(function ($activity) {
|
||||
return $activity->causer_type . '_' . $activity->causer_id;
|
||||
});
|
||||
|
||||
$totalDeleted = 0;
|
||||
|
||||
foreach ($presenceActivities as $profileKey => $activities) {
|
||||
// Keep only the latest records for each profile as defined in config
|
||||
if ($activities->count() > timebank_config('presence_settings.keep_last_presence_updates')) {
|
||||
$toDelete = $activities->skip(4)->pluck('id');
|
||||
$deleted = Activity::whereIn('id', $toDelete)->delete();
|
||||
$totalDeleted += $deleted;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Deleted {$totalDeleted} old presence records, keeping last " . timebank_config('presence_settings.keep_last_presence_updates') . " per profile.");
|
||||
}
|
||||
}
|
||||
115
app/Console/Commands/ClearPresenceCommand.php
Normal file
115
app/Console/Commands/ClearPresenceCommand.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\PresenceService;
|
||||
use Illuminate\Console\Command;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class ClearPresenceCommand extends Command
|
||||
{
|
||||
protected $signature = 'presence:clear {profile_id?} {guard?}';
|
||||
protected $description = 'Clear presence cache and set profile offline';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$profileId = $this->argument('profile_id');
|
||||
$guard = $this->argument('guard');
|
||||
|
||||
if ($profileId && $guard) {
|
||||
// Clear specific profile
|
||||
$this->clearSpecificProfile($profileId, $guard);
|
||||
} else {
|
||||
// Clear all presence data
|
||||
$this->clearAllPresence();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function clearSpecificProfile($profileId, $guard)
|
||||
{
|
||||
$this->info("Clearing presence for Profile ID: {$profileId}, Guard: {$guard}");
|
||||
|
||||
$modelClass = $this->getModelClass($guard);
|
||||
$profile = $modelClass::find($profileId);
|
||||
|
||||
if (!$profile) {
|
||||
$this->error("Profile not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set offline
|
||||
$presenceService = app(PresenceService::class);
|
||||
$presenceService->setUserOffline($profile, $guard);
|
||||
|
||||
// Clear caches
|
||||
\Cache::forget("presence_{$guard}_{$profileId}");
|
||||
\Cache::forget("presence_last_update_{$guard}_{$profileId}");
|
||||
\Cache::forget("online_users_{$guard}_" . PresenceService::ONLINE_THRESHOLD_MINUTES);
|
||||
|
||||
$this->info("✓ Presence cleared for {$profile->name}");
|
||||
}
|
||||
|
||||
protected function clearAllPresence()
|
||||
{
|
||||
$this->info("Clearing ALL presence data...");
|
||||
|
||||
$guards = ['web', 'admin', 'bank', 'organization'];
|
||||
|
||||
foreach ($guards as $guard) {
|
||||
// Clear online users cache
|
||||
\Cache::forget("online_users_{$guard}_" . PresenceService::ONLINE_THRESHOLD_MINUTES);
|
||||
$this->line("✓ Cleared online users cache for {$guard} guard");
|
||||
}
|
||||
|
||||
// Clear all presence cache keys
|
||||
$cacheKeys = \Cache::getRedis()->keys('*presence_*');
|
||||
foreach ($cacheKeys as $key) {
|
||||
// Remove the Redis prefix from the key
|
||||
$cleanKey = str_replace(\Cache::getRedis()->getOptions()->prefix, '', $key);
|
||||
\Cache::forget($cleanKey);
|
||||
}
|
||||
|
||||
$this->info("✓ Cleared all presence cache keys");
|
||||
|
||||
// Optionally mark all users as offline in activity log
|
||||
if ($this->confirm('Do you want to mark all users as offline in the activity log?', false)) {
|
||||
$presenceService = app(PresenceService::class);
|
||||
|
||||
// Get all recent online activities
|
||||
$recentActivities = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('properties->status', 'online')
|
||||
->where('created_at', '>=', now()->subMinutes(60))
|
||||
->with('subject')
|
||||
->get();
|
||||
|
||||
foreach ($recentActivities as $activity) {
|
||||
if ($activity->subject) {
|
||||
$props = is_string($activity->properties)
|
||||
? json_decode($activity->properties, true)
|
||||
: $activity->properties;
|
||||
$guard = $props['guard'] ?? 'web';
|
||||
$presenceService->setUserOffline($activity->subject, $guard);
|
||||
$this->line(" - Set {$activity->subject->name} offline ({$guard})");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("✓ Marked all users as offline");
|
||||
}
|
||||
|
||||
$this->info("Done!");
|
||||
}
|
||||
|
||||
protected function getModelClass($guard)
|
||||
{
|
||||
$map = [
|
||||
'web' => \App\Models\User::class,
|
||||
'admin' => \App\Models\Admin::class,
|
||||
'bank' => \App\Models\Bank::class,
|
||||
'organization' => \App\Models\Organization::class,
|
||||
];
|
||||
|
||||
return $map[$guard] ?? \App\Models\User::class;
|
||||
}
|
||||
}
|
||||
524
app/Console/Commands/ConfigMerge.php
Normal file
524
app/Console/Commands/ConfigMerge.php
Normal file
@@ -0,0 +1,524 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ConfigMerge extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'config:merge
|
||||
{file? : Config file to merge (themes, timebank-default, timebank_cc)}
|
||||
{--all : Merge all config files}
|
||||
{--dry-run : Show what would change without applying}
|
||||
{--force : Skip confirmation prompts}
|
||||
{--restore : Restore config from backup}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Merge new configuration keys from .example files into active configs';
|
||||
|
||||
/**
|
||||
* Config files that can be merged
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $mergeableConfigs = [
|
||||
'themes' => 'config/themes.php',
|
||||
'timebank-default' => 'config/timebank-default.php',
|
||||
'timebank_cc' => 'config/timebank_cc.php',
|
||||
];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
// Handle restore option
|
||||
if ($this->option('restore')) {
|
||||
return $this->restoreFromBackup();
|
||||
}
|
||||
|
||||
// Determine which files to merge
|
||||
$filesToMerge = $this->getFilesToMerge();
|
||||
|
||||
if (empty($filesToMerge)) {
|
||||
$this->error('No valid config files specified.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$anyChanges = false;
|
||||
$results = [];
|
||||
|
||||
foreach ($filesToMerge as $name => $path) {
|
||||
$result = $this->mergeConfigFile($name, $path);
|
||||
$results[$name] = $result;
|
||||
|
||||
if ($result['hasChanges']) {
|
||||
$anyChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
if (!$this->option('dry-run')) {
|
||||
$this->newLine();
|
||||
$this->line('═══════════════════════════════════════════════════════');
|
||||
$this->info('Config Merge Summary');
|
||||
$this->line('═══════════════════════════════════════════════════════');
|
||||
|
||||
foreach ($results as $name => $result) {
|
||||
if ($result['hasChanges'] && $result['applied']) {
|
||||
$this->info("✓ {$name}: {$result['newKeyCount']} new keys merged");
|
||||
} elseif ($result['hasChanges'] && !$result['applied']) {
|
||||
$this->warn("⊘ {$name}: {$result['newKeyCount']} new keys available (not applied)");
|
||||
} else {
|
||||
$this->comment(" {$name}: Up to date");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $anyChanges ? 0 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of files to merge
|
||||
*/
|
||||
protected function getFilesToMerge(): array
|
||||
{
|
||||
if ($this->option('all')) {
|
||||
return $this->mergeableConfigs;
|
||||
}
|
||||
|
||||
$file = $this->argument('file');
|
||||
if (!$file) {
|
||||
$this->error('Please specify a config file or use --all');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!isset($this->mergeableConfigs[$file])) {
|
||||
$this->error("Unknown config file: {$file}");
|
||||
$this->line('Available files: ' . implode(', ', array_keys($this->mergeableConfigs)));
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$file => $this->mergeableConfigs[$file]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a single config file
|
||||
*/
|
||||
protected function mergeConfigFile(string $name, string $path): array
|
||||
{
|
||||
$examplePath = $path . '.example';
|
||||
|
||||
// Check if files exist
|
||||
if (!File::exists($examplePath)) {
|
||||
$this->warn("⊘ {$name}: Example file not found ({$examplePath})");
|
||||
return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0];
|
||||
}
|
||||
|
||||
if (!File::exists($path)) {
|
||||
$this->warn("⊘ {$name}: Active config not found ({$path}) - run deployment first");
|
||||
return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0];
|
||||
}
|
||||
|
||||
// Load configs
|
||||
try {
|
||||
$currentConfig = include $path;
|
||||
$exampleConfig = include $examplePath;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("✗ {$name}: Failed to load config - {$e->getMessage()}");
|
||||
return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0];
|
||||
}
|
||||
|
||||
if (!is_array($currentConfig) || !is_array($exampleConfig)) {
|
||||
$this->error("✗ {$name}: Invalid config format");
|
||||
return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0];
|
||||
}
|
||||
|
||||
// Find new keys
|
||||
$newKeys = $this->findNewKeys($currentConfig, $exampleConfig);
|
||||
|
||||
if (empty($newKeys)) {
|
||||
$this->comment(" {$name}: No new keys found");
|
||||
return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0];
|
||||
}
|
||||
|
||||
// Display changes
|
||||
$this->newLine();
|
||||
$this->line("─────────────────────────────────────────────────────");
|
||||
$this->info("Config: {$name}");
|
||||
$this->line("─────────────────────────────────────────────────────");
|
||||
$this->warn("Found " . count($newKeys) . " new configuration key(s):");
|
||||
$this->newLine();
|
||||
|
||||
foreach ($newKeys as $keyPath => $value) {
|
||||
$this->line(" <fg=green>+</> {$keyPath}");
|
||||
$this->line(" <fg=gray>" . $this->formatValue($value) . "</>");
|
||||
}
|
||||
|
||||
// Dry run - stop here
|
||||
if ($this->option('dry-run')) {
|
||||
return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)];
|
||||
}
|
||||
|
||||
// Confirm merge
|
||||
if (!$this->option('force')) {
|
||||
if (!$this->confirm("Merge these keys into {$name}?", false)) {
|
||||
$this->comment("Skipped {$name}");
|
||||
return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)];
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup
|
||||
$backupPath = $this->createBackup($path);
|
||||
if (!$backupPath) {
|
||||
$this->error("✗ {$name}: Failed to create backup");
|
||||
return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)];
|
||||
}
|
||||
|
||||
$this->line(" Backup created: {$backupPath}");
|
||||
|
||||
// Perform merge
|
||||
$mergedConfig = $this->deepMergeNewKeys($currentConfig, $exampleConfig);
|
||||
|
||||
// Write merged config
|
||||
if (!$this->writeConfig($path, $mergedConfig)) {
|
||||
$this->error("✗ {$name}: Failed to write merged config");
|
||||
// Restore from backup
|
||||
File::copy($backupPath, $path);
|
||||
return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)];
|
||||
}
|
||||
|
||||
// Validate merged config can be loaded
|
||||
try {
|
||||
$testLoad = include $path;
|
||||
if (!is_array($testLoad)) {
|
||||
throw new \Exception('Config does not return an array');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("✗ {$name}: Merged config is invalid - {$e->getMessage()}");
|
||||
// Restore from backup
|
||||
File::copy($backupPath, $path);
|
||||
return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)];
|
||||
}
|
||||
|
||||
// Log the merge
|
||||
Log::channel('single')->info('Config merged', [
|
||||
'file' => $name,
|
||||
'path' => $path,
|
||||
'new_keys' => array_keys($newKeys),
|
||||
'new_key_count' => count($newKeys),
|
||||
'backup' => $backupPath,
|
||||
]);
|
||||
|
||||
$this->info("✓ {$name}: Successfully merged " . count($newKeys) . " new keys");
|
||||
|
||||
return ['hasChanges' => true, 'applied' => true, 'newKeyCount' => count($newKeys)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find new keys in example config that don't exist in current config
|
||||
*/
|
||||
protected function findNewKeys(array $current, array $example, string $prefix = ''): array
|
||||
{
|
||||
$newKeys = [];
|
||||
|
||||
foreach ($example as $key => $value) {
|
||||
$keyPath = $prefix ? "{$prefix}.{$key}" : $key;
|
||||
|
||||
if (!array_key_exists($key, $current)) {
|
||||
// This is a new key
|
||||
$newKeys[$keyPath] = $value;
|
||||
} elseif (is_array($value) && is_array($current[$key])) {
|
||||
// Both are arrays - recurse to find nested new keys
|
||||
$nestedKeys = $this->findNewKeys($current[$key], $value, $keyPath);
|
||||
$newKeys = array_merge($newKeys, $nestedKeys);
|
||||
}
|
||||
// Otherwise key exists and is not both arrays - skip (preserve current value)
|
||||
}
|
||||
|
||||
return $newKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform deep merge of new keys only
|
||||
*/
|
||||
protected function deepMergeNewKeys(array $current, array $example): array
|
||||
{
|
||||
$result = $current;
|
||||
|
||||
foreach ($example as $key => $value) {
|
||||
if (!array_key_exists($key, $current)) {
|
||||
// New key - add it
|
||||
$result[$key] = $value;
|
||||
} elseif (is_array($value) && is_array($current[$key])) {
|
||||
// Both are arrays - recurse
|
||||
$result[$key] = $this->deepMergeNewKeys($current[$key], $value);
|
||||
}
|
||||
// Otherwise preserve current value
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup of the config file
|
||||
*/
|
||||
protected function createBackup(string $path): ?string
|
||||
{
|
||||
$backupDir = storage_path('config-backups');
|
||||
|
||||
if (!File::isDirectory($backupDir)) {
|
||||
File::makeDirectory($backupDir, 0755, true);
|
||||
}
|
||||
|
||||
$filename = basename($path);
|
||||
$timestamp = date('Y-m-d_His');
|
||||
$backupPath = "{$backupDir}/{$filename}.backup.{$timestamp}";
|
||||
|
||||
if (File::copy($path, $backupPath)) {
|
||||
// Cleanup old backups (keep last 5)
|
||||
$this->cleanupOldBackups($backupDir, $filename);
|
||||
return $backupPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old backup files
|
||||
*/
|
||||
protected function cleanupOldBackups(string $backupDir, string $filename): void
|
||||
{
|
||||
$pattern = "{$backupDir}/{$filename}.backup.*";
|
||||
$backups = glob($pattern);
|
||||
|
||||
if (count($backups) > 5) {
|
||||
// Sort by modification time (oldest first)
|
||||
usort($backups, function ($a, $b) {
|
||||
return filemtime($a) - filemtime($b);
|
||||
});
|
||||
|
||||
// Delete oldest backups, keeping last 5
|
||||
$toDelete = array_slice($backups, 0, count($backups) - 5);
|
||||
foreach ($toDelete as $backup) {
|
||||
File::delete($backup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write config array to file
|
||||
*/
|
||||
protected function writeConfig(string $path, array $config): bool
|
||||
{
|
||||
$content = "<?php\n\n";
|
||||
$content .= "use Illuminate\\Validation\\Rule;\n\n";
|
||||
$content .= "return " . $this->varExportPretty($config, 0) . ";\n";
|
||||
|
||||
return File::put($path, $content) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty print PHP array
|
||||
*/
|
||||
protected function varExportPretty($var, int $indent = 0): string
|
||||
{
|
||||
$indentStr = str_repeat(' ', $indent * 4);
|
||||
$nextIndentStr = str_repeat(' ', ($indent + 1) * 4);
|
||||
|
||||
if (!is_array($var)) {
|
||||
if (is_string($var)) {
|
||||
return "'" . addcslashes($var, "'\\") . "'";
|
||||
}
|
||||
if (is_bool($var)) {
|
||||
return $var ? 'true' : 'false';
|
||||
}
|
||||
if (is_null($var)) {
|
||||
return 'null';
|
||||
}
|
||||
return var_export($var, true);
|
||||
}
|
||||
|
||||
if (empty($var)) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
$output = "[\n";
|
||||
foreach ($var as $key => $value) {
|
||||
$output .= $nextIndentStr;
|
||||
|
||||
// Format key
|
||||
if (is_int($key)) {
|
||||
$output .= $key;
|
||||
} else {
|
||||
$output .= "'" . addcslashes($key, "'\\") . "'";
|
||||
}
|
||||
|
||||
$output .= ' => ';
|
||||
|
||||
// Format value
|
||||
if (is_array($value)) {
|
||||
$output .= $this->varExportPretty($value, $indent + 1);
|
||||
} elseif (is_string($value)) {
|
||||
$output .= "'" . addcslashes($value, "'\\") . "'";
|
||||
} elseif (is_bool($value)) {
|
||||
$output .= $value ? 'true' : 'false';
|
||||
} elseif (is_null($value)) {
|
||||
$output .= 'null';
|
||||
} elseif ($value instanceof \Closure) {
|
||||
// Handle closures - just export as string representation
|
||||
$output .= var_export($value, true);
|
||||
} else {
|
||||
$output .= var_export($value, true);
|
||||
}
|
||||
|
||||
$output .= ",\n";
|
||||
}
|
||||
$output .= $indentStr . ']';
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for display
|
||||
*/
|
||||
protected function formatValue($value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
if (empty($value)) {
|
||||
return '[]';
|
||||
}
|
||||
$preview = array_slice($value, 0, 3, true);
|
||||
$formatted = [];
|
||||
foreach ($preview as $k => $v) {
|
||||
$formatted[] = is_int($k) ? $this->formatValue($v) : "{$k}: " . $this->formatValue($v);
|
||||
}
|
||||
$result = '[' . implode(', ', $formatted);
|
||||
if (count($value) > 3) {
|
||||
$result .= ', ... +' . (count($value) - 3) . ' more';
|
||||
}
|
||||
return $result . ']';
|
||||
}
|
||||
if (is_string($value)) {
|
||||
return strlen($value) > 50 ? '"' . substr($value, 0, 47) . '..."' : "\"{$value}\"";
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
if (is_null($value)) {
|
||||
return 'null';
|
||||
}
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore config from backup
|
||||
*/
|
||||
protected function restoreFromBackup(): int
|
||||
{
|
||||
$backupDir = storage_path('config-backups');
|
||||
|
||||
if (!File::isDirectory($backupDir)) {
|
||||
$this->error('No backups found');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// List available backups
|
||||
$backups = File::glob("{$backupDir}/*.backup.*");
|
||||
|
||||
if (empty($backups)) {
|
||||
$this->error('No backups found');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Group by config file
|
||||
$grouped = [];
|
||||
foreach ($backups as $backup) {
|
||||
$basename = basename($backup);
|
||||
preg_match('/^(.+?)\.backup\.(.+)$/', $basename, $matches);
|
||||
if ($matches) {
|
||||
$configFile = $matches[1];
|
||||
$timestamp = $matches[2];
|
||||
$grouped[$configFile][] = [
|
||||
'path' => $backup,
|
||||
'timestamp' => $timestamp,
|
||||
'time' => filemtime($backup),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by time (newest first)
|
||||
foreach ($grouped as &$backupList) {
|
||||
usort($backupList, function ($a, $b) {
|
||||
return $b['time'] - $a['time'];
|
||||
});
|
||||
}
|
||||
|
||||
// Display available backups
|
||||
$this->info('Available backups:');
|
||||
$this->newLine();
|
||||
|
||||
$options = [];
|
||||
$i = 1;
|
||||
foreach ($grouped as $configFile => $backupList) {
|
||||
$this->line("<fg=yellow>{$configFile}</>");
|
||||
foreach ($backupList as $backup) {
|
||||
$date = date('Y-m-d H:i:s', $backup['time']);
|
||||
$this->line(" {$i}. {$date}");
|
||||
$options[$i] = [
|
||||
'file' => $configFile,
|
||||
'path' => $backup['path'],
|
||||
];
|
||||
$i++;
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// Get user choice
|
||||
$choice = $this->ask('Enter backup number to restore (or 0 to cancel)');
|
||||
|
||||
if ($choice === '0' || !isset($options[(int)$choice])) {
|
||||
$this->comment('Restore cancelled');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$selected = $options[(int)$choice];
|
||||
$configPath = "config/{$selected['file']}";
|
||||
|
||||
if (!File::exists($configPath)) {
|
||||
$this->error("Config file not found: {$configPath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$this->confirm("Restore {$selected['file']} from backup?", false)) {
|
||||
$this->comment('Restore cancelled');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Create backup of current config before restoring
|
||||
$currentBackupPath = $this->createBackup($configPath);
|
||||
if ($currentBackupPath) {
|
||||
$this->line("Current config backed up to: {$currentBackupPath}");
|
||||
}
|
||||
|
||||
// Restore
|
||||
if (File::copy($selected['path'], $configPath)) {
|
||||
$this->info("✓ Successfully restored {$selected['file']}");
|
||||
return 0;
|
||||
} else {
|
||||
$this->error("✗ Failed to restore config");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
174
app/Console/Commands/DatabaseUpdate.php
Normal file
174
app/Console/Commands/DatabaseUpdate.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DatabaseUpdate extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'database:update';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Apply database updates for schema changes and data migrations';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Starting database updates...');
|
||||
|
||||
// Apply all update methods
|
||||
$this->renameAssociationToPlatformOrganization();
|
||||
$this->addLocationNotSpecifiedCountry();
|
||||
|
||||
$this->info('Database updates completed successfully!');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename 'Association' to 'Timebank Organization' across the database
|
||||
* Updates categories table from Association/PlatformOrganization to TimebankOrganization
|
||||
* Updates category_translations table with proper translations
|
||||
*/
|
||||
private function renameAssociationToPlatformOrganization()
|
||||
{
|
||||
$this->info('Renaming Association to Timebank Organization...');
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Update categories table: change type from Association to TimebankOrganization
|
||||
$categoriesUpdated = DB::table('categories')
|
||||
->where('type', 'SiteContents\\Static\\Association')
|
||||
->update(['type' => 'SiteContents\\Static\\TimebankOrganization']);
|
||||
|
||||
if ($categoriesUpdated > 0) {
|
||||
$this->info(" ✓ Updated {$categoriesUpdated} category record(s) from Association");
|
||||
}
|
||||
|
||||
// Also update any PlatformOrganization entries to TimebankOrganization
|
||||
$platformUpdated = DB::table('categories')
|
||||
->where('type', 'SiteContents\\Static\\PlatformOrganization')
|
||||
->update(['type' => 'SiteContents\\Static\\TimebankOrganization']);
|
||||
|
||||
if ($platformUpdated > 0) {
|
||||
$this->info(" ✓ Updated {$platformUpdated} category record(s) from PlatformOrganization");
|
||||
}
|
||||
|
||||
if ($categoriesUpdated === 0 && $platformUpdated === 0) {
|
||||
$this->warn(' ℹ No categories found to update');
|
||||
}
|
||||
|
||||
// Update category_translations table
|
||||
// Get the category ID for TimebankOrganization
|
||||
$category = DB::table('categories')
|
||||
->where('type', 'SiteContents\\Static\\TimebankOrganization')
|
||||
->first();
|
||||
|
||||
if ($category) {
|
||||
$translations = [
|
||||
'en' => [
|
||||
'name' => 'Timebank organization page',
|
||||
'slug' => 'timebank-organization-page',
|
||||
],
|
||||
'nl' => [
|
||||
'name' => 'Timebank organisatie pagina',
|
||||
'slug' => 'timebank-organisatie-pagina',
|
||||
],
|
||||
'de' => [
|
||||
'name' => 'Timebank-Organisation Seite',
|
||||
'slug' => 'timebank-organisation-seite',
|
||||
],
|
||||
'es' => [
|
||||
'name' => 'Organización de Timebank página',
|
||||
'slug' => 'organizacion-de-timebank-pagina',
|
||||
],
|
||||
'fr' => [
|
||||
'name' => 'Organisation de Timebank page',
|
||||
'slug' => 'organisation-de-timebank-page',
|
||||
],
|
||||
];
|
||||
|
||||
$translationsUpdated = 0;
|
||||
foreach ($translations as $locale => $data) {
|
||||
$updated = DB::table('category_translations')
|
||||
->where('category_id', $category->id)
|
||||
->where('locale', $locale)
|
||||
->update([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'],
|
||||
]);
|
||||
|
||||
$translationsUpdated += $updated;
|
||||
}
|
||||
|
||||
if ($translationsUpdated > 0) {
|
||||
$this->info(" ✓ Updated {$translationsUpdated} category translation(s) (name and slug)");
|
||||
} else {
|
||||
$this->warn(' ℹ No category translations found to update');
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
$this->info(' ✓ Renamed to Timebank Organization successfully');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(' ✗ Failed to rename: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the "Location not specified" placeholder country (id=10, code=XX) with translations.
|
||||
* Used for profiles whose Cyclos country was unmapped (code 863).
|
||||
*/
|
||||
private function addLocationNotSpecifiedCountry()
|
||||
{
|
||||
$this->info('Adding "Location not specified" placeholder country...');
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
DB::table('countries')->upsert(
|
||||
[['id' => 10, 'code' => 'XX', 'flag' => '🌐', 'phonecode' => '']],
|
||||
['id']
|
||||
);
|
||||
|
||||
$locales = [
|
||||
['id' => 37, 'locale' => 'en', 'name' => '~ Location not specified'],
|
||||
['id' => 38, 'locale' => 'nl', 'name' => '~ Locatie niet opgegeven'],
|
||||
['id' => 39, 'locale' => 'fr', 'name' => '~ Emplacement non précisé'],
|
||||
['id' => 40, 'locale' => 'es', 'name' => '~ Ubicación no especificada'],
|
||||
['id' => 41, 'locale' => 'de', 'name' => '~ Standort nicht angegeben'],
|
||||
];
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
DB::table('country_locales')->upsert(
|
||||
[['id' => $locale['id'], 'country_id' => 10, 'name' => $locale['name'], 'alias' => null, 'locale' => $locale['locale']]],
|
||||
['id']
|
||||
);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
$this->info(' ✓ "Location not specified" country added/updated');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(' ✗ Failed to add location not specified country: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
92
app/Console/Commands/DebugPresenceCommand.php
Normal file
92
app/Console/Commands/DebugPresenceCommand.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Services\PresenceService;
|
||||
use Illuminate\Console\Command;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class DebugPresenceCommand extends Command
|
||||
{
|
||||
protected $signature = 'presence:debug {profile_id} {guard=admin}';
|
||||
protected $description = 'Debug presence status for a specific profile';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$profileId = $this->argument('profile_id');
|
||||
$guard = $this->argument('guard');
|
||||
|
||||
$this->info("Debugging presence for Profile ID: {$profileId}, Guard: {$guard}");
|
||||
$this->info("---");
|
||||
|
||||
// Get the model
|
||||
$modelClass = $this->getModelClass($guard);
|
||||
$profile = $modelClass::find($profileId);
|
||||
|
||||
if (!$profile) {
|
||||
$this->error("Profile not found!");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Profile: {$profile->name}");
|
||||
$this->info("---");
|
||||
|
||||
// Check cache
|
||||
$cacheKey = "presence_{$guard}_{$profileId}";
|
||||
$cached = \Cache::get($cacheKey);
|
||||
$this->info("Cache Key: {$cacheKey}");
|
||||
$this->info("Cached Data: " . ($cached ? json_encode($cached) : 'NULL'));
|
||||
$this->info("---");
|
||||
|
||||
// Check recent activities
|
||||
$this->info("Recent presence activities (last 10 minutes):");
|
||||
$activities = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $profileId)
|
||||
->where('subject_type', get_class($profile))
|
||||
->where('properties->guard', $guard)
|
||||
->where('created_at', '>=', now()->subMinutes(10))
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
if ($activities->isEmpty()) {
|
||||
$this->warn("No recent activities found");
|
||||
} else {
|
||||
foreach ($activities as $activity) {
|
||||
$props = is_string($activity->properties)
|
||||
? json_decode($activity->properties, true)
|
||||
: $activity->properties;
|
||||
$status = $props['status'] ?? 'unknown';
|
||||
$this->line("- {$activity->created_at}: {$status} ({$activity->description})");
|
||||
}
|
||||
}
|
||||
$this->info("---");
|
||||
|
||||
// Check PresenceService
|
||||
$presenceService = app(PresenceService::class);
|
||||
$isOnline = $presenceService->isUserOnline($profile, $guard);
|
||||
$lastSeen = $presenceService->getUserLastSeen($profile, $guard);
|
||||
|
||||
$this->info("PresenceService->isUserOnline(): " . ($isOnline ? 'TRUE' : 'FALSE'));
|
||||
$this->info("PresenceService->getUserLastSeen(): " . ($lastSeen ? $lastSeen->toDateTimeString() : 'NULL'));
|
||||
$this->info("---");
|
||||
|
||||
// Check authentication
|
||||
$isAuthenticated = auth($guard)->check() && auth($guard)->id() == $profileId;
|
||||
$this->info("Is authenticated in {$guard} guard: " . ($isAuthenticated ? 'TRUE' : 'FALSE'));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function getModelClass($guard)
|
||||
{
|
||||
$map = [
|
||||
'web' => \App\Models\User::class,
|
||||
'admin' => \App\Models\Admin::class,
|
||||
'bank' => \App\Models\Bank::class,
|
||||
'organization' => \App\Models\Organization::class,
|
||||
];
|
||||
|
||||
return $map[$guard] ?? \App\Models\User::class;
|
||||
}
|
||||
}
|
||||
93
app/Console/Commands/FixConversationDurations.php
Normal file
93
app/Console/Commands/FixConversationDurations.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Namu\WireChat\Models\Conversation;
|
||||
|
||||
class FixConversationDurations extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'wirechat:fix-conversation-durations {--dry-run : Show what would be fixed without making changes}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Fix conversations with incorrect disappearing_duration values (exceeding INT max)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Checking for conversations with invalid disappearing_duration values...');
|
||||
|
||||
// Get correct duration in seconds from config
|
||||
$durationInDays = timebank_config('wirechat.disappearing_messages.duration', 30);
|
||||
$correctDuration = $durationInDays * 86400; // Convert days to seconds
|
||||
|
||||
// Maximum value for signed INT in MySQL (2,147,483,647)
|
||||
$maxIntValue = 2147483647;
|
||||
|
||||
// Find conversations with duration that exceeds INT max or is suspiciously large
|
||||
$conversations = Conversation::where('disappearing_duration', '>', $maxIntValue)
|
||||
->orWhere('disappearing_duration', '>', 100000000) // Suspiciously large (> 1157 days)
|
||||
->get();
|
||||
|
||||
if ($conversations->isEmpty()) {
|
||||
$this->info('No conversations with invalid durations found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->warn("Found {$conversations->count()} conversations with invalid durations:");
|
||||
$this->newLine();
|
||||
|
||||
$table = [];
|
||||
foreach ($conversations as $conversation) {
|
||||
$table[] = [
|
||||
'ID' => $conversation->id,
|
||||
'Current Duration' => number_format($conversation->disappearing_duration),
|
||||
'Correct Duration' => number_format($correctDuration),
|
||||
'Started At' => $conversation->disappearing_started_at ? $conversation->disappearing_started_at->format('Y-m-d H:i') : 'NULL',
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(['ID', 'Current Duration', 'Correct Duration', 'Started At'], $table);
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('Dry-run mode: No changes made.');
|
||||
$this->info("Would update {$conversations->count()} conversations to duration: {$correctDuration} seconds ({$durationInDays} days)");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$this->confirm("Update {$conversations->count()} conversations to duration {$correctDuration} seconds ({$durationInDays} days)?", true)) {
|
||||
$this->comment('Update cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($conversations as $conversation) {
|
||||
try {
|
||||
$conversation->disappearing_duration = $correctDuration;
|
||||
$conversation->save();
|
||||
$count++;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Failed to update conversation {$conversation->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("✓ Successfully updated {$count} conversations");
|
||||
$this->info("Duration set to: {$correctDuration} seconds ({$durationInDays} days)");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
780
app/Console/Commands/ImportExportTags.php
Normal file
780
app/Console/Commands/ImportExportTags.php
Normal file
@@ -0,0 +1,780 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ImportExportTags extends Command
|
||||
{
|
||||
protected $signature = 'tags:import-export
|
||||
{action : Action to perform: import, export-categories, export-tags, remove-group}
|
||||
{file? : Specific JSON file path (optional for import - will process all files in imports/tags/ if not specified)}
|
||||
{--category-id= : Export tags for specific category ID only}
|
||||
{--locale= : Export tags for specific locale only}
|
||||
{--output= : Output file name (optional, defaults to generated name)}
|
||||
{--dry-run : Preview import without making changes}';
|
||||
|
||||
protected $description = 'Import/export tags and categories in JSON format for AI tag generation';
|
||||
|
||||
protected array $supportedLocales = ['en', 'nl', 'fr', 'es', 'de'];
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$action = $this->argument('action');
|
||||
|
||||
switch ($action) {
|
||||
case 'import':
|
||||
return $this->importTags();
|
||||
case 'export-categories':
|
||||
return $this->exportCategories();
|
||||
case 'export-tags':
|
||||
return $this->exportTags();
|
||||
case 'remove-group':
|
||||
return $this->removeTagGroups();
|
||||
default:
|
||||
$this->error("Invalid action. Use: import, export-categories, export-tags, or remove-group");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import tags from JSON file(s)
|
||||
*/
|
||||
protected function importTags(): int
|
||||
{
|
||||
$filePath = $this->argument('file');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
// If no specific file provided, process all files in imports/tags/ folder
|
||||
if (!$filePath) {
|
||||
return $this->importFromFolder($dryRun);
|
||||
}
|
||||
|
||||
// Process single file
|
||||
return $this->importSingleFile($filePath, $dryRun);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import all JSON files from imports/tags/ folder
|
||||
*/
|
||||
protected function importFromFolder(bool $dryRun): int
|
||||
{
|
||||
$importFolder = 'imports/tags';
|
||||
|
||||
// Create folder if it doesn't exist
|
||||
if (!is_dir($importFolder)) {
|
||||
mkdir($importFolder, 0755, true);
|
||||
$this->info("Created imports folder: {$importFolder}");
|
||||
$this->info("Please place your JSON files in this folder and run the command again.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get all JSON files from the folder
|
||||
$jsonFiles = glob($importFolder . '/*.json');
|
||||
|
||||
if (empty($jsonFiles)) {
|
||||
$this->warn("No JSON files found in {$importFolder}/");
|
||||
$this->info("Please place your JSON files in this folder and run the command again.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found " . count($jsonFiles) . " JSON files to process:");
|
||||
foreach ($jsonFiles as $file) {
|
||||
$this->line(" - " . basename($file));
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn("DRY RUN MODE - No changes will be made");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
$totalImported = 0;
|
||||
$totalSkipped = 0;
|
||||
$totalErrors = 0;
|
||||
$processedFiles = 0;
|
||||
$failedFiles = 0;
|
||||
|
||||
foreach ($jsonFiles as $filePath) {
|
||||
$fileName = basename($filePath);
|
||||
$this->info("Processing file: {$fileName}");
|
||||
$this->line(str_repeat('=', 50));
|
||||
|
||||
try {
|
||||
$result = $this->importSingleFile($filePath, $dryRun);
|
||||
|
||||
if ($result === 0) {
|
||||
$processedFiles++;
|
||||
// Get the stats from the last import (we'll need to modify importSingleFile to return stats)
|
||||
} else {
|
||||
$failedFiles++;
|
||||
$this->error("Failed to process {$fileName}");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$failedFiles++;
|
||||
$this->error("Error processing {$fileName}: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// Final summary
|
||||
$this->info("Overall Import Summary:");
|
||||
$this->line(" Files processed successfully: {$processedFiles}");
|
||||
$this->line(" Files failed: {$failedFiles}");
|
||||
$this->line(" Total files: " . count($jsonFiles));
|
||||
|
||||
return $failedFiles > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import tags from a single JSON file
|
||||
*/
|
||||
protected function importSingleFile(string $filePath, bool $dryRun): int
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
$this->error("File not found: {$filePath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$jsonContent = file_get_contents($filePath);
|
||||
$data = json_decode($jsonContent, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->error('Invalid JSON format: ' . json_last_error_msg());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!isset($data['tags']) || !is_array($data['tags'])) {
|
||||
$this->error('JSON must contain a "tags" array');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Found " . count($data['tags']) . " tags to import from " . basename($filePath));
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($data['tags'] as $index => $tagData) {
|
||||
try {
|
||||
$result = $this->importSingleTag($tagData, $index + 1, $dryRun);
|
||||
|
||||
if ($result === 'imported') {
|
||||
$imported++;
|
||||
} elseif ($result === 'skipped') {
|
||||
$skipped++;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Error processing tag at index " . ($index + 1) . ": " . $e->getMessage());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("File Summary for " . basename($filePath) . ":");
|
||||
$this->line(" Imported: {$imported}");
|
||||
$this->line(" Skipped: {$skipped}");
|
||||
$this->line(" Errors: {$errors}");
|
||||
|
||||
// Move processed file to processed folder (only if not dry run and no errors)
|
||||
if (!$dryRun && $errors === 0) {
|
||||
$this->moveProcessedFile($filePath);
|
||||
}
|
||||
|
||||
return $errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move processed file to processed folder
|
||||
*/
|
||||
protected function moveProcessedFile(string $filePath): void
|
||||
{
|
||||
$processedFolder = 'imports/tags/processed';
|
||||
|
||||
if (!is_dir($processedFolder)) {
|
||||
mkdir($processedFolder, 0755, true);
|
||||
}
|
||||
|
||||
$fileName = basename($filePath);
|
||||
$timestamp = now()->format('Y-m-d-H-i-s');
|
||||
$newFileName = pathinfo($fileName, PATHINFO_FILENAME) . "_{$timestamp}.json";
|
||||
$newPath = $processedFolder . '/' . $newFileName;
|
||||
|
||||
if (rename($filePath, $newPath)) {
|
||||
$this->line(" ✓ Moved processed file to: {$newPath}");
|
||||
} else {
|
||||
$this->warn(" Could not move processed file to processed folder");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a single tag from the JSON data
|
||||
*/
|
||||
protected function importSingleTag(array $tagData, int $index, bool $dryRun): string
|
||||
{
|
||||
// Validate required fields
|
||||
if (!isset($tagData['translations']) || !is_array($tagData['translations'])) {
|
||||
throw new \InvalidArgumentException("Tag {$index}: 'translations' field is required and must be an array");
|
||||
}
|
||||
|
||||
if (!isset($tagData['category'])) {
|
||||
throw new \InvalidArgumentException("Tag {$index}: 'category' field is required");
|
||||
}
|
||||
|
||||
$category = $tagData['category'];
|
||||
|
||||
// Verify category exists
|
||||
$categoryId = null;
|
||||
|
||||
if (isset($category['id'])) {
|
||||
$categoryId = $category['id'];
|
||||
} elseif (isset($category['name'])) {
|
||||
// Find category by name
|
||||
$categoryRecord = DB::table('category_translations')
|
||||
->join('categories', 'category_translations.category_id', '=', 'categories.id')
|
||||
->where('category_translations.name', $category['name'])
|
||||
->where('category_translations.locale', 'en')
|
||||
->where('categories.type', 'App\\Models\\Tag')
|
||||
->first();
|
||||
|
||||
if (!$categoryRecord) {
|
||||
throw new \InvalidArgumentException("Tag {$index}: Category '{$category['name']}' not found");
|
||||
}
|
||||
|
||||
$categoryId = $categoryRecord->category_id;
|
||||
} else {
|
||||
throw new \InvalidArgumentException("Tag {$index}: Category must have either 'id' or 'name' field");
|
||||
}
|
||||
|
||||
// Verify category exists
|
||||
$categoryExists = DB::table('categories')
|
||||
->where('id', $categoryId)
|
||||
->where('type', 'App\\Models\\Tag')
|
||||
->exists();
|
||||
|
||||
if (!$categoryExists) {
|
||||
throw new \InvalidArgumentException("Tag {$index}: Category with ID {$categoryId} not found");
|
||||
}
|
||||
|
||||
// Check if this exact tag group already exists
|
||||
$existingTagGroup = $this->findExistingTagGroup($tagData['translations'], $categoryId);
|
||||
|
||||
if ($existingTagGroup) {
|
||||
$this->line(" Skipping existing tag group with translations: " . implode(', ', array_values($tagData['translations'])));
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" Would create new tag group for category {$categoryId}:");
|
||||
foreach ($tagData['translations'] as $locale => $tagName) {
|
||||
if (in_array($locale, $this->supportedLocales)) {
|
||||
$this->line(" {$locale}: '{$tagName}'");
|
||||
}
|
||||
}
|
||||
return 'imported';
|
||||
}
|
||||
|
||||
// Create new context for this tag group (each imported tag group gets its own context)
|
||||
$contextId = DB::table('taggable_contexts')->insertGetId([
|
||||
'category_id' => $categoryId,
|
||||
'updated_by_user' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->line(" Created new context {$contextId} for category {$categoryId}");
|
||||
|
||||
$createdTags = [];
|
||||
|
||||
// Process each translation - each gets its own tag_id
|
||||
foreach ($tagData['translations'] as $locale => $tagName) {
|
||||
if (!in_array($locale, $this->supportedLocales)) {
|
||||
$this->warn(" Skipping unsupported locale: {$locale}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the tag
|
||||
$tagId = DB::table('taggable_tags')->insertGetId([
|
||||
'name' => $tagName,
|
||||
'normalized' => Str::lower($tagName),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Create locale record
|
||||
DB::table('taggable_locales')->insert([
|
||||
'taggable_tag_id' => $tagId,
|
||||
'locale' => $locale,
|
||||
'comment' => '',
|
||||
'updated_by_user' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Link to the same context (this creates the relationship between all translations)
|
||||
DB::table('taggable_locale_context')->insert([
|
||||
'tag_id' => $tagId,
|
||||
'context_id' => $contextId,
|
||||
]);
|
||||
|
||||
$createdTags[] = ['locale' => $locale, 'name' => $tagName, 'id' => $tagId];
|
||||
$this->line(" ✓ Created tag: '{$tagName}' ({$locale}) with ID {$tagId}");
|
||||
}
|
||||
|
||||
$this->line(" ✓ Created tag group with " . count($createdTags) . " translations in context {$contextId}");
|
||||
|
||||
return 'imported';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this exact tag group already exists
|
||||
*/
|
||||
protected function findExistingTagGroup(array $translations, int $categoryId): ?int
|
||||
{
|
||||
// Look for any existing tag with any of these names in the same category
|
||||
foreach ($translations as $locale => $tagName) {
|
||||
if (!in_array($locale, $this->supportedLocales)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingTag = DB::table('taggable_tags as tt')
|
||||
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
|
||||
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
|
||||
->join('taggable_contexts as tc', 'tlc.context_id', '=', 'tc.id')
|
||||
->where('tt.name', $tagName)
|
||||
->where('tl.locale', $locale)
|
||||
->where('tc.category_id', $categoryId)
|
||||
->first();
|
||||
|
||||
if ($existingTag) {
|
||||
// Found an existing tag, now check if the context has all the same translations
|
||||
$contextId = $existingTag->context_id;
|
||||
|
||||
$existingTranslations = DB::table('taggable_tags as tt')
|
||||
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
|
||||
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
|
||||
->where('tlc.context_id', $contextId)
|
||||
->pluck('tt.name', 'tl.locale')
|
||||
->toArray();
|
||||
|
||||
// Check if existing translations match exactly
|
||||
$inputTranslationsFiltered = array_intersect_key($translations, array_flip($this->supportedLocales));
|
||||
|
||||
if (
|
||||
count(array_diff_assoc($inputTranslationsFiltered, $existingTranslations)) === 0 &&
|
||||
count(array_diff_assoc($existingTranslations, $inputTranslationsFiltered)) === 0
|
||||
) {
|
||||
return $contextId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export categories to JSON format for AI tag generation
|
||||
*/
|
||||
protected function exportCategories(): int
|
||||
{
|
||||
$this->info('Exporting categories...');
|
||||
|
||||
// Create export folder if it doesn't exist
|
||||
$exportFolder = 'exports/categories';
|
||||
if (!is_dir($exportFolder)) {
|
||||
mkdir($exportFolder, 0755, true);
|
||||
$this->info("Created export folder: {$exportFolder}");
|
||||
}
|
||||
|
||||
$categories = DB::table('categories')
|
||||
->join('category_translations', 'categories.id', '=', 'category_translations.category_id')
|
||||
->where('categories.type', 'App\\Models\\Tag')
|
||||
->where('category_translations.locale', 'en')
|
||||
->select(
|
||||
'categories.id',
|
||||
'category_translations.name',
|
||||
'category_translations.slug',
|
||||
'categories.color'
|
||||
)
|
||||
->orderBy('category_translations.name')
|
||||
->get();
|
||||
|
||||
$exportData = [
|
||||
'metadata' => [
|
||||
'exported_at' => now()->toISOString(),
|
||||
'total_categories' => $categories->count(),
|
||||
'purpose' => 'AI tag generation input',
|
||||
'supported_locales' => $this->supportedLocales,
|
||||
'instructions' => [
|
||||
'Please generate tags for the categories below',
|
||||
'Follow the example_format exactly',
|
||||
'Include translations for all supported locales',
|
||||
'Use the category id and name provided',
|
||||
'Generate 10-20 relevant tags per category',
|
||||
],
|
||||
],
|
||||
'categories' => $categories->map(function ($category) {
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'name' => $category->name,
|
||||
'slug' => $category->slug,
|
||||
'color' => $category->color,
|
||||
];
|
||||
})->toArray(),
|
||||
'example_format' => [
|
||||
'tags' => [
|
||||
[
|
||||
'translations' => [
|
||||
'en' => 'Example English Tag',
|
||||
'nl' => 'Voorbeeld Nederlandse Tag',
|
||||
'fr' => 'Exemple de Tag Français',
|
||||
'es' => 'Ejemplo de Etiqueta Española',
|
||||
'de' => 'Beispiel Deutsche Tag',
|
||||
],
|
||||
'category' => [
|
||||
'id' => 1,
|
||||
'name' => 'Category Name'
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$fileName = $this->option('output') ?: 'categories-for-ai-' . now()->format('Y-m-d-H-i-s') . '.json';
|
||||
$fullPath = $exportFolder . '/' . $fileName;
|
||||
|
||||
file_put_contents($fullPath, json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$this->info("✓ Exported {$categories->count()} categories to: {$fullPath}");
|
||||
$this->newLine();
|
||||
$this->info("Next steps:");
|
||||
$this->line("1. Send the exported file to an AI service");
|
||||
$this->line("2. Ask AI to generate tags following the example_format");
|
||||
$this->line("3. Save AI response as JSON file(s) in imports/tags/ folder");
|
||||
$this->line("4. Run: php artisan tags:import-export import");
|
||||
$this->newLine();
|
||||
$this->info("Folder structure:");
|
||||
$this->line(" exports/categories/ - Generated category files for AI");
|
||||
$this->line(" imports/tags/ - Place AI-generated tag files here");
|
||||
$this->line(" imports/tags/processed/ - Successfully processed files are moved here");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive removal of tag groups
|
||||
*/
|
||||
protected function removeTagGroups(): int
|
||||
{
|
||||
$this->info('Interactive Tag Group Removal');
|
||||
$this->newLine();
|
||||
$this->info('This tool allows you to remove entire tag groups (all translations of the same concept).');
|
||||
$this->warn('⚠️ You can press Ctrl+C at any time to abort this script.');
|
||||
$this->newLine();
|
||||
|
||||
$removedGroups = 0;
|
||||
|
||||
while (true) {
|
||||
$tagId = $this->ask('Enter a taggable_tag_id to remove its entire group (or "exit" to quit)');
|
||||
|
||||
if (strtolower(trim($tagId)) === 'exit') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!is_numeric($tagId) || $tagId <= 0) {
|
||||
$this->error('Please enter a valid numeric tag ID.');
|
||||
continue;
|
||||
}
|
||||
|
||||
$tagId = (int) $tagId;
|
||||
|
||||
try {
|
||||
$result = $this->removeTagGroup($tagId);
|
||||
if ($result) {
|
||||
$removedGroups++;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Session summary: Removed {$removedGroups} tag groups.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single tag group by tag ID
|
||||
*/
|
||||
protected function removeTagGroup(int $tagId): bool
|
||||
{
|
||||
// Find the tag and its context
|
||||
$tagInfo = DB::table('taggable_tags as tt')
|
||||
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
|
||||
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
|
||||
->join('taggable_contexts as tc', 'tlc.context_id', '=', 'tc.id')
|
||||
->join('categories as c', 'tc.category_id', '=', 'c.id')
|
||||
->join('category_translations as ct', function ($join) {
|
||||
$join->on('c.id', '=', 'ct.category_id')
|
||||
->where('ct.locale', '=', 'en');
|
||||
})
|
||||
->where('tt.tag_id', $tagId)
|
||||
->select(
|
||||
'tt.tag_id',
|
||||
'tt.name',
|
||||
'tl.locale',
|
||||
'tc.id as context_id',
|
||||
'c.id as category_id',
|
||||
'ct.name as category_name'
|
||||
)
|
||||
->first();
|
||||
|
||||
if (!$tagInfo) {
|
||||
$this->error("Tag with ID {$tagId} not found.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get all tags in the same context (the entire tag group)
|
||||
$tagGroup = DB::table('taggable_tags as tt')
|
||||
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
|
||||
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
|
||||
->where('tlc.context_id', $tagInfo->context_id)
|
||||
->select(
|
||||
'tt.tag_id',
|
||||
'tt.name',
|
||||
'tl.locale'
|
||||
)
|
||||
->orderBy('tl.locale')
|
||||
->get();
|
||||
|
||||
if ($tagGroup->isEmpty()) {
|
||||
$this->error("No tag group found for tag ID {$tagId}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Display the tag group information
|
||||
$this->info("Found tag group in category: {$tagInfo->category_name}");
|
||||
$this->info("Context ID: {$tagInfo->context_id}");
|
||||
$this->newLine();
|
||||
$this->warn("The following tags will be PERMANENTLY removed:");
|
||||
|
||||
foreach ($tagGroup as $tag) {
|
||||
$this->line(" • ID {$tag->tag_id}: '{$tag->name}' ({$tag->locale})");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$confirmMessage = "Are you sure you want to remove this entire tag group ({$tagGroup->count()} tags)?";
|
||||
|
||||
if (!$this->confirm($confirmMessage, false)) {
|
||||
$this->info('Removal cancelled.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Perform the removal
|
||||
$this->info('Removing tag group...');
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($tagGroup, $tagInfo) {
|
||||
$tagIds = $tagGroup->pluck('tag_id')->toArray();
|
||||
|
||||
// Remove context links
|
||||
DB::table('taggable_locale_context')
|
||||
->whereIn('tag_id', $tagIds)
|
||||
->delete();
|
||||
|
||||
// Remove taggable relationships (if any exist)
|
||||
DB::table('taggable_taggables')
|
||||
->whereIn('tag_id', $tagIds)
|
||||
->delete();
|
||||
|
||||
// Remove locale records
|
||||
DB::table('taggable_locales')
|
||||
->whereIn('taggable_tag_id', $tagIds)
|
||||
->delete();
|
||||
|
||||
// Remove the tags themselves
|
||||
DB::table('taggable_tags')
|
||||
->whereIn('tag_id', $tagIds)
|
||||
->delete();
|
||||
|
||||
// Remove the context if it's now empty
|
||||
$remainingTags = DB::table('taggable_locale_context')
|
||||
->where('context_id', $tagInfo->context_id)
|
||||
->count();
|
||||
|
||||
if ($remainingTags === 0) {
|
||||
DB::table('taggable_contexts')
|
||||
->where('id', $tagInfo->context_id)
|
||||
->delete();
|
||||
|
||||
$this->line(" ✓ Removed empty context {$tagInfo->context_id}");
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("✓ Successfully removed tag group ({$tagGroup->count()} tags)");
|
||||
|
||||
// Show what was removed
|
||||
foreach ($tagGroup as $tag) {
|
||||
$this->line(" ✓ Removed: '{$tag->name}' ({$tag->locale}) - ID {$tag->tag_id}");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to remove tag group: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing tag group helper method
|
||||
|
||||
/**
|
||||
* Export existing tags to JSON format
|
||||
*/
|
||||
protected function exportTags(): int
|
||||
{
|
||||
$categoryId = $this->option('category-id');
|
||||
$locale = $this->option('locale');
|
||||
|
||||
$this->info('Exporting tags...');
|
||||
|
||||
// Create export folder if it doesn't exist
|
||||
$exportFolder = 'exports/tags';
|
||||
if (!is_dir($exportFolder)) {
|
||||
mkdir($exportFolder, 0755, true);
|
||||
$this->info("Created export folder: {$exportFolder}");
|
||||
}
|
||||
|
||||
$query = DB::table('taggable_tags as tt')
|
||||
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
|
||||
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
|
||||
->join('taggable_contexts as tc', 'tlc.context_id', '=', 'tc.id')
|
||||
->join('categories as c', 'tc.category_id', '=', 'c.id')
|
||||
->join('category_translations as ct', function ($join) {
|
||||
$join->on('c.id', '=', 'ct.category_id')
|
||||
->where('ct.locale', '=', 'en');
|
||||
})
|
||||
->select(
|
||||
'tt.tag_id',
|
||||
'tt.name',
|
||||
'tl.locale',
|
||||
'tc.id as context_id',
|
||||
'c.id as category_id',
|
||||
'ct.name as category_name'
|
||||
);
|
||||
|
||||
if ($categoryId) {
|
||||
$query->where('c.id', $categoryId);
|
||||
}
|
||||
|
||||
if ($locale) {
|
||||
$query->where('tl.locale', $locale);
|
||||
}
|
||||
|
||||
$tags = $query->orderBy('c.id')
|
||||
->orderBy('tc.id')
|
||||
->orderBy('tl.locale')
|
||||
->get();
|
||||
|
||||
// Group tags by context (since each context now contains one tag group)
|
||||
$contextGroups = [];
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$contextId = $tag->context_id;
|
||||
|
||||
if (!isset($contextGroups[$contextId])) {
|
||||
$contextGroups[$contextId] = [
|
||||
'category_id' => $tag->category_id,
|
||||
'category_name' => $tag->category_name,
|
||||
'translations' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// Add this translation to the context group
|
||||
$contextGroups[$contextId]['translations'][$tag->locale] = $tag->name;
|
||||
}
|
||||
|
||||
// Convert to export format - each context becomes one tag group
|
||||
$exportTags = [];
|
||||
foreach ($contextGroups as $contextId => $contextData) {
|
||||
// Skip contexts that don't have any translations (shouldn't happen, but safety check)
|
||||
if (empty($contextData['translations'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If filtering by locale, only include contexts that have that locale
|
||||
if ($locale && !isset($contextData['translations'][$locale])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exportTags[] = [
|
||||
'translations' => $contextData['translations'],
|
||||
'category' => [
|
||||
'id' => $contextData['category_id'],
|
||||
'name' => $contextData['category_name'],
|
||||
],
|
||||
'_metadata' => [
|
||||
'context_id' => $contextId,
|
||||
'translation_count' => count($contextData['translations']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$exportData = [
|
||||
'metadata' => [
|
||||
'exported_at' => now()->toISOString(),
|
||||
'total_tag_groups' => count($exportTags),
|
||||
'total_contexts' => count($contextGroups),
|
||||
'filters' => [
|
||||
'category_id' => $categoryId,
|
||||
'locale' => $locale,
|
||||
],
|
||||
'structure_info' => [
|
||||
'each_tag_group' => 'represents one context with 1-5 translations',
|
||||
'context_per_concept' => 'each context contains translations of the same concept',
|
||||
'max_translations_per_group' => 5,
|
||||
'supported_locales' => $this->supportedLocales,
|
||||
],
|
||||
],
|
||||
'tags' => $exportTags,
|
||||
];
|
||||
|
||||
$fileName = $this->option('output') ?: 'tags-backup-' . now()->format('Y-m-d-H-i-s') . '.json';
|
||||
|
||||
if ($categoryId) {
|
||||
$fileName = str_replace('.json', "-category-{$categoryId}.json", $fileName);
|
||||
}
|
||||
|
||||
if ($locale) {
|
||||
$fileName = str_replace('.json', "-{$locale}.json", $fileName);
|
||||
}
|
||||
|
||||
$fullPath = $exportFolder . '/' . $fileName;
|
||||
file_put_contents($fullPath, json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$this->info("✓ Exported " . count($exportTags) . " tag groups from " . count($contextGroups) . " contexts to: {$fullPath}");
|
||||
$this->newLine();
|
||||
|
||||
// Show some statistics
|
||||
$translationCounts = array_count_values(array_map(fn($tag) => count($tag['translations']), $exportTags));
|
||||
$this->info("Translation distribution:");
|
||||
foreach ($translationCounts as $count => $groups) {
|
||||
$this->line(" {$count} translation(s): {$groups} tag groups");
|
||||
}
|
||||
|
||||
if ($locale) {
|
||||
$this->newLine();
|
||||
$this->info("Note: When filtering by locale '{$locale}', only contexts containing that locale are included.");
|
||||
$this->info("Each exported tag group shows all translations for that context, not just the filtered locale.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
316
app/Console/Commands/ManageBouncedMailings.php
Normal file
316
app/Console/Commands/ManageBouncedMailings.php
Normal file
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MailingBounce;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ManageBouncedMailings extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'mailings:manage-bounces
|
||||
{action : Action to perform: list, stats, suppress, unsuppress, cleanup, check-thresholds}
|
||||
{--email= : Specific email address for suppress/unsuppress/check actions}
|
||||
{--days= : Number of days for cleanup (default: 90)}
|
||||
{--type= : Bounce type filter: hard, soft, complaint}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Manage bounced email addresses from mailings with threshold-based actions';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$action = $this->argument('action');
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$this->listBounces();
|
||||
break;
|
||||
case 'stats':
|
||||
$this->showStats();
|
||||
break;
|
||||
case 'suppress':
|
||||
$this->suppressEmail();
|
||||
break;
|
||||
case 'unsuppress':
|
||||
$this->unsuppressEmail();
|
||||
break;
|
||||
case 'cleanup':
|
||||
$this->cleanupOldBounces();
|
||||
break;
|
||||
case 'check-thresholds':
|
||||
$this->checkThresholds();
|
||||
break;
|
||||
default:
|
||||
$this->error("Invalid action. Use: list, stats, suppress, unsuppress, cleanup, check-thresholds");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* List bounced emails
|
||||
*/
|
||||
protected function listBounces()
|
||||
{
|
||||
$query = MailingBounce::query();
|
||||
|
||||
if ($type = $this->option('type')) {
|
||||
$query->where('bounce_type', $type);
|
||||
}
|
||||
|
||||
$bounces = $query->orderBy('bounced_at', 'desc')->get();
|
||||
|
||||
if ($bounces->isEmpty()) {
|
||||
$this->info('No bounced emails found.');
|
||||
return;
|
||||
}
|
||||
|
||||
$headers = ['Email', 'Type', 'Reason', 'Bounced At', 'Suppressed'];
|
||||
$rows = $bounces->map(function ($bounce) {
|
||||
return [
|
||||
$bounce->email,
|
||||
$bounce->bounce_type,
|
||||
Str::limit($bounce->bounce_reason, 50),
|
||||
$bounce->bounced_at->format('Y-m-d H:i'),
|
||||
$bounce->is_suppressed ? 'Yes' : 'No',
|
||||
];
|
||||
});
|
||||
|
||||
$this->table($headers, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show bounce statistics
|
||||
*/
|
||||
protected function showStats()
|
||||
{
|
||||
$config = timebank_config('mailing.bounce_thresholds', []);
|
||||
$windowDays = $config['counting_window_days'] ?? 30;
|
||||
|
||||
$totalBounces = MailingBounce::count();
|
||||
$suppressedEmails = MailingBounce::where('is_suppressed', true)->count();
|
||||
$hardBounces = MailingBounce::where('bounce_type', 'hard')->count();
|
||||
$softBounces = MailingBounce::where('bounce_type', 'soft')->count();
|
||||
$recentBounces = MailingBounce::where('bounced_at', '>=', now()->subDays(7))->count();
|
||||
$windowBounces = MailingBounce::where('bounced_at', '>=', now()->subDays($windowDays))->count();
|
||||
|
||||
$this->info("Mailing Bounce Statistics:");
|
||||
$this->line("Total bounces: {$totalBounces}");
|
||||
$this->line("Suppressed emails: {$suppressedEmails}");
|
||||
$this->line("Hard bounces: {$hardBounces}");
|
||||
$this->line("Soft bounces: {$softBounces}");
|
||||
$this->line("Recent bounces (7 days): {$recentBounces}");
|
||||
$this->line("Bounces in threshold window ({$windowDays} days): {$windowBounces}");
|
||||
|
||||
// Threshold configuration
|
||||
$this->line("\nThreshold Configuration:");
|
||||
$this->line(" Suppression threshold: " . ($config['suppression_threshold'] ?? 3));
|
||||
$this->line(" Verification reset threshold: " . ($config['verification_reset_threshold'] ?? 2));
|
||||
$this->line(" Counting window: {$windowDays} days");
|
||||
|
||||
// Top bouncing domains
|
||||
$topDomains = MailingBounce::select(DB::raw('SUBSTRING_INDEX(email, "@", -1) as domain, COUNT(*) as count'))
|
||||
->groupBy('domain')
|
||||
->orderBy('count', 'desc')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
if ($topDomains->isNotEmpty()) {
|
||||
$this->line("\nTop bouncing domains:");
|
||||
foreach ($topDomains as $domain) {
|
||||
$this->line(" {$domain->domain}: {$domain->count} bounces");
|
||||
}
|
||||
}
|
||||
|
||||
// Emails approaching thresholds
|
||||
$this->showEmailsApproachingThresholds($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show emails that are approaching bounce thresholds
|
||||
*/
|
||||
protected function showEmailsApproachingThresholds(array $config): void
|
||||
{
|
||||
$suppressionThreshold = $config['suppression_threshold'] ?? 3;
|
||||
$verificationResetThreshold = $config['verification_reset_threshold'] ?? 2;
|
||||
$windowDays = $config['counting_window_days'] ?? 30;
|
||||
|
||||
// Find emails with high bounce counts but not yet suppressed
|
||||
$emailsNearThreshold = MailingBounce::select('email', DB::raw('COUNT(*) as bounce_count'))
|
||||
->where('bounce_type', 'hard')
|
||||
->where('bounced_at', '>=', now()->subDays($windowDays))
|
||||
->where('is_suppressed', false)
|
||||
->groupBy('email')
|
||||
->having('bounce_count', '>=', max(1, $verificationResetThreshold - 1))
|
||||
->orderBy('bounce_count', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
if ($emailsNearThreshold->isNotEmpty()) {
|
||||
$this->line("\nEmails Approaching Thresholds:");
|
||||
$headers = ['Email', 'Hard Bounces', 'Status'];
|
||||
$rows = $emailsNearThreshold->map(function ($item) use ($suppressionThreshold, $verificationResetThreshold) {
|
||||
$status = [];
|
||||
if ($item->bounce_count >= $suppressionThreshold) {
|
||||
$status[] = 'Will suppress';
|
||||
} elseif ($item->bounce_count >= $verificationResetThreshold) {
|
||||
$status[] = 'Will reset verification';
|
||||
}
|
||||
if (empty($status)) {
|
||||
$status[] = 'Approaching threshold';
|
||||
}
|
||||
|
||||
return [
|
||||
$item->email,
|
||||
$item->bounce_count,
|
||||
implode(', ', $status)
|
||||
];
|
||||
});
|
||||
|
||||
$this->table($headers, $rows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check thresholds for a specific email or all emails
|
||||
*/
|
||||
protected function checkThresholds(): void
|
||||
{
|
||||
$email = $this->option('email');
|
||||
|
||||
if ($email) {
|
||||
$stats = MailingBounce::getBounceStats($email);
|
||||
$this->displayEmailStats($stats);
|
||||
} else {
|
||||
$this->info("Checking all emails against current thresholds...");
|
||||
|
||||
// Get all emails with bounces
|
||||
$emails = MailingBounce::distinct('email')->pluck('email');
|
||||
|
||||
$problematicEmails = [];
|
||||
foreach ($emails as $emailAddress) {
|
||||
$stats = MailingBounce::getBounceStats($emailAddress);
|
||||
|
||||
$config = timebank_config('mailing.bounce_thresholds', []);
|
||||
$suppressionThreshold = $config['suppression_threshold'] ?? 3;
|
||||
$verificationResetThreshold = $config['verification_reset_threshold'] ?? 2;
|
||||
|
||||
if ($stats['recent_hard_bounces'] >= $verificationResetThreshold) {
|
||||
$problematicEmails[] = $stats;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($problematicEmails)) {
|
||||
$this->info("No emails exceed the current thresholds.");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("Found " . count($problematicEmails) . " emails exceeding thresholds:");
|
||||
|
||||
foreach ($problematicEmails as $stats) {
|
||||
$this->displayEmailStats($stats);
|
||||
$this->line('---');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display bounce statistics for a specific email
|
||||
*/
|
||||
protected function displayEmailStats(array $stats): void
|
||||
{
|
||||
$config = timebank_config('mailing.bounce_thresholds', []);
|
||||
$suppressionThreshold = $config['suppression_threshold'] ?? 3;
|
||||
$verificationResetThreshold = $config['verification_reset_threshold'] ?? 2;
|
||||
|
||||
$this->line("Email: {$stats['email']}");
|
||||
$this->line(" Total bounces: {$stats['total_bounces']}");
|
||||
$this->line(" Recent bounces ({$stats['window_days']} days): {$stats['recent_bounces']}");
|
||||
$this->line(" Recent hard bounces: {$stats['recent_hard_bounces']}");
|
||||
$this->line(" Currently suppressed: " . ($stats['is_suppressed'] ? 'Yes' : 'No'));
|
||||
|
||||
// Status assessment
|
||||
if ($stats['recent_hard_bounces'] >= $suppressionThreshold) {
|
||||
$this->line(" 🔴 Status: Should be suppressed ({$stats['recent_hard_bounces']} >= {$suppressionThreshold})");
|
||||
} elseif ($stats['recent_hard_bounces'] >= $verificationResetThreshold) {
|
||||
$this->line(" 🟡 Status: Should reset verification ({$stats['recent_hard_bounces']} >= {$verificationResetThreshold})");
|
||||
} else {
|
||||
$this->line(" 🟢 Status: Below thresholds");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress a specific email
|
||||
*/
|
||||
protected function suppressEmail()
|
||||
{
|
||||
$email = $this->option('email');
|
||||
|
||||
if (!$email) {
|
||||
$email = $this->ask('Enter email address to suppress');
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->error('Invalid email address.');
|
||||
return;
|
||||
}
|
||||
|
||||
MailingBounce::suppressEmail($email, 'Manually suppressed via command');
|
||||
$this->info("Email {$email} has been suppressed.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsuppress a specific email
|
||||
*/
|
||||
protected function unsuppressEmail()
|
||||
{
|
||||
$email = $this->option('email');
|
||||
|
||||
if (!$email) {
|
||||
$email = $this->ask('Enter email address to unsuppress');
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->error('Invalid email address.');
|
||||
return;
|
||||
}
|
||||
|
||||
$updated = MailingBounce::where('email', $email)
|
||||
->update(['is_suppressed' => false]);
|
||||
|
||||
if ($updated > 0) {
|
||||
$this->info("Email {$email} has been unsuppressed.");
|
||||
} else {
|
||||
$this->warn("Email {$email} was not found in bounce list.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old bounces
|
||||
*/
|
||||
protected function cleanupOldBounces()
|
||||
{
|
||||
$days = $this->option('days') ?: 90;
|
||||
|
||||
if (!$this->confirm("Delete bounces older than {$days} days? This will only remove old soft bounces, keeping hard bounces and suppressions.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = MailingBounce::where('bounce_type', 'soft')
|
||||
->where('is_suppressed', false)
|
||||
->where('bounced_at', '<', now()->subDays($days))
|
||||
->delete();
|
||||
|
||||
$this->info("Deleted {$deleted} old soft bounce records.");
|
||||
}
|
||||
}
|
||||
93
app/Console/Commands/MarkInactiveProfiles.php
Normal file
93
app/Console/Commands/MarkInactiveProfiles.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MarkInactiveProfiles extends Command
|
||||
{
|
||||
protected $signature = 'profiles:mark-inactive';
|
||||
|
||||
protected $description = 'Mark profiles as inactive when they have not logged in for configured number of days';
|
||||
|
||||
protected $daysThreshold;
|
||||
protected $logFile;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
// Get configured threshold from platform config
|
||||
$this->daysThreshold = timebank_config('profile_inactive.days_not_logged_in');
|
||||
$this->logFile = storage_path('logs/mark-inactive-profiles.log');
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Checking profiles for inactivity...');
|
||||
$this->logMessage('=== Starting profile inactivity check ===');
|
||||
|
||||
$totalMarked = 0;
|
||||
$thresholdDate = now()->subDays($this->daysThreshold);
|
||||
|
||||
// Process Users
|
||||
$users = User::whereNotNull('last_login_at')
|
||||
->whereNull('inactive_at') // Only profiles not already marked inactive
|
||||
->where('last_login_at', '<', $thresholdDate)
|
||||
->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$result = $this->markInactive($user, 'User');
|
||||
if ($result) $totalMarked++;
|
||||
}
|
||||
|
||||
// Process Organizations
|
||||
$organizations = Organization::whereNotNull('last_login_at')
|
||||
->whereNull('inactive_at') // Only profiles not already marked inactive
|
||||
->where('last_login_at', '<', $thresholdDate)
|
||||
->get();
|
||||
|
||||
foreach ($organizations as $organization) {
|
||||
$result = $this->markInactive($organization, 'Organization');
|
||||
if ($result) $totalMarked++;
|
||||
}
|
||||
|
||||
$this->info("Processing complete: {$totalMarked} profiles marked as inactive");
|
||||
$this->logMessage("=== Completed: {$totalMarked} profiles marked inactive ===\n");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function markInactive($profile, $profileType)
|
||||
{
|
||||
try {
|
||||
$lastLoginAt = \Carbon\Carbon::parse($profile->last_login_at);
|
||||
$daysSinceLogin = now()->diffInDays($lastLoginAt);
|
||||
|
||||
// Set inactive_at to current timestamp
|
||||
$profile->inactive_at = now();
|
||||
$profile->save();
|
||||
|
||||
$this->logMessage("[{$profileType}] Marked INACTIVE: {$profile->name} (ID: {$profile->id}) - Not logged in for {$daysSinceLogin} days (last login: {$lastLoginAt->format('Y-m-d')})");
|
||||
$this->info("[{$profileType}] Marked inactive: {$profile->name} ({$daysSinceLogin} days)");
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
$this->logMessage("[{$profileType}] ERROR marking {$profile->name} (ID: {$profile->id}) inactive: {$e->getMessage()}");
|
||||
$this->error("[{$profileType}] Error: {$profile->name}: {$e->getMessage()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function logMessage($message)
|
||||
{
|
||||
$timestamp = now()->format('Y-m-d H:i:s');
|
||||
$logEntry = "[{$timestamp}] {$message}\n";
|
||||
|
||||
file_put_contents($this->logFile, $logEntry, FILE_APPEND);
|
||||
Log::info($message);
|
||||
}
|
||||
}
|
||||
1214
app/Console/Commands/MigrateCyclosCommand.php
Normal file
1214
app/Console/Commands/MigrateCyclosCommand.php
Normal file
File diff suppressed because it is too large
Load Diff
109
app/Console/Commands/MigrateCyclosGiftAccounts.php
Normal file
109
app/Console/Commands/MigrateCyclosGiftAccounts.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Transaction;
|
||||
use App\Traits\AccountInfoTrait;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MigrateCyclosGiftAccounts extends Command
|
||||
{
|
||||
use AccountInfoTrait; // For using the getBalance() method
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'migrate:cyclos-gift-accounts';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'This migrates balances from all "gift" accounts to the primary account of the same owner.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
|
||||
$this->info('Starting gift account migration...');
|
||||
|
||||
$giftAccounts = Account::where('name', 'gift')->get();
|
||||
|
||||
if ($giftAccounts->isEmpty()) {
|
||||
$this->info('No gift accounts found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found {$giftAccounts->count()} gift accounts to process.");
|
||||
|
||||
foreach ($giftAccounts as $fromAccount) {
|
||||
$this->line("Processing gift account ID: {$fromAccount->id} for owner: {$fromAccount->accountable->name}");
|
||||
|
||||
// 1. Get the balance. If it's zero or less, there's nothing to do.
|
||||
$balance = $this->getBalance($fromAccount->id);
|
||||
if ($balance <= 0) {
|
||||
$this->line(" -> Balance is {$balance}. Nothing to migrate. Skipping.");
|
||||
continue;
|
||||
}
|
||||
$this->line(" -> Balance to migrate: " . tbFormat($balance));
|
||||
|
||||
// 2. Find the destination account (the first non-gift account for the same owner)
|
||||
$toAccount = Account::where('accountable_id', $fromAccount->accountable_id)
|
||||
->where('accountable_type', $fromAccount->accountable_type)
|
||||
->where('name', '!=', 'gift')
|
||||
->first();
|
||||
|
||||
if (!$toAccount) {
|
||||
$this->error(" -> No destination account found for owner ID {$fromAccount->accountable_id}. Skipping.");
|
||||
Log::warning("Gift Migration: No destination account for gift account ID {$fromAccount->id}");
|
||||
continue;
|
||||
}
|
||||
$this->line(" -> Destination account found: ID {$toAccount->id} ('{$toAccount->name}')");
|
||||
|
||||
// 3. Prepare the transfer details
|
||||
$transactionTypeId = 6; // Migration type
|
||||
$description = "Migration of balance from gift account (ID: {$fromAccount->id})";
|
||||
|
||||
// 4. Perform the database transaction
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$transfer = new Transaction();
|
||||
$transfer->from_account_id = $fromAccount->id;
|
||||
$transfer->to_account_id = $toAccount->id;
|
||||
$transfer->amount = $balance;
|
||||
$transfer->description = $description;
|
||||
$transfer->transaction_type_id = $transactionTypeId;
|
||||
$transfer->creator_user_id = null; // No user in a command context
|
||||
$transfer->save();
|
||||
|
||||
DB::commit();
|
||||
$this->info(" -> SUCCESS: Migrated " . tbFormat($balance) . " to account ID {$toAccount->id}. Transaction ID: {$transfer->id}");
|
||||
Log::info("Gift Migration Success: Migrated {$balance} from account {$fromAccount->id} to {$toAccount->id}. TxID: {$transfer->id}");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(" -> FAILED: An error occurred during the database transaction: " . $e->getMessage());
|
||||
Log::error("Gift Migration DB Error for account {$fromAccount->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// After the loop, mark all processed gift accounts as inactive
|
||||
$this->info('Marking all processed gift accounts as inactive...');
|
||||
$giftAccountIds = $giftAccounts->pluck('id');
|
||||
Account::whereIn('id', $giftAccountIds)->update(['inactive_at' => now()]);
|
||||
$this->info('All gift accounts have been marked as inactive.');
|
||||
|
||||
$this->info('Gift account migration finished.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
493
app/Console/Commands/MigrateCyclosProfilesCommand.php
Normal file
493
app/Console/Commands/MigrateCyclosProfilesCommand.php
Normal file
@@ -0,0 +1,493 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Bank;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MigrateCyclosProfilesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'migrate:cyclos-profiles {source_db? : Name of the source Cyclos database (skips prompt if provided)}';
|
||||
protected $description = 'Migrates the Cyclos profile contents from the old Cyclos database to the new Laravel database';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
// Use argument if provided, otherwise fall back to cache (set by migrate:cyclos during db:seed)
|
||||
$sourceDb = $this->argument('source_db') ?: cache()->get('cyclos_migration_source_db');
|
||||
|
||||
if (empty($sourceDb)) {
|
||||
// If not in cache, ask for it
|
||||
$this->info('The source Cyclos database should be imported into MySQL and accessible from this application.');
|
||||
$this->info('Hint: Place the database dump in the app root and import with: mysql -u root -p < cyclos_dump.sql');
|
||||
$sourceDb = $this->ask('Enter the name of the source Cyclos database');
|
||||
|
||||
if (empty($sourceDb)) {
|
||||
$this->error('Source database name is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Remove .sql extension if present
|
||||
if (str_ends_with(strtolower($sourceDb), '.sql')) {
|
||||
$sourceDb = substr($sourceDb, 0, -4);
|
||||
$this->info("Using database name: {$sourceDb}");
|
||||
}
|
||||
|
||||
// Verify the database exists
|
||||
$databases = DB::select('SHOW DATABASES');
|
||||
$databaseNames = array_map(fn($db) => $db->Database, $databases);
|
||||
|
||||
if (!in_array($sourceDb, $databaseNames)) {
|
||||
$this->error("Database '{$sourceDb}' does not exist.");
|
||||
$this->info('Available databases:');
|
||||
foreach ($databaseNames as $name) {
|
||||
if (!in_array($name, ['information_schema', 'mysql', 'performance_schema', 'sys'])) {
|
||||
$this->line(" - {$name}");
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
$this->info("Using source database from previous step: {$sourceDb}");
|
||||
}
|
||||
|
||||
$destinationDb = env('DB_DATABASE');
|
||||
|
||||
// Migrate phone field
|
||||
$tables = ['users', 'organizations', 'banks'];
|
||||
|
||||
foreach ($tables as $tableName) {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$affectedRows = DB::affectingStatement("
|
||||
UPDATE `{$destinationDb}`.`{$tableName}` dest
|
||||
JOIN (
|
||||
SELECT
|
||||
c.member_id,
|
||||
LEFT(c.string_value, 20) AS phone -- Truncate to 20 characters
|
||||
FROM `{$sourceDb}`.`custom_field_values` c
|
||||
WHERE c.field_id = 7
|
||||
) src ON dest.cyclos_id = src.member_id
|
||||
SET dest.phone = src.phone
|
||||
");
|
||||
DB::commit();
|
||||
$this->info(ucfirst($tableName) . " phone field updated for $affectedRows records");
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(ucfirst($tableName) . " phone field migration failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Migrate locations
|
||||
|
||||
$countryCodeMap = [
|
||||
860 => 2, // BE
|
||||
861 => 7, // PT
|
||||
862 => 1, // NL
|
||||
863 => 10, // country not set / other country → "Location not specified"
|
||||
];
|
||||
$cityCodeMap = [
|
||||
864 => 188, // Amsterdam
|
||||
865 => 200, // Haarlem
|
||||
866 => 316, // Leiden
|
||||
867 => 305, // The Hague
|
||||
868 => 300, // Delft
|
||||
869 => 331, // Rotterdam
|
||||
870 => 272, // Utrecht
|
||||
881 => 345, // Brussels
|
||||
];
|
||||
|
||||
$updatedRecordsCount = 0;
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// Wrap the migration calls in a function that returns the count of updated records
|
||||
$updatedRecordsCount += $this->migrateLocationData('User', $destinationDb, $sourceDb, $countryCodeMap, $cityCodeMap);
|
||||
$updatedRecordsCount += $this->migrateLocationData('Organization', $destinationDb, $sourceDb, $countryCodeMap, $cityCodeMap);
|
||||
$updatedRecordsCount += $this->migrateLocationData('Bank', $destinationDb, $sourceDb, $countryCodeMap, $cityCodeMap);
|
||||
|
||||
DB::commit();
|
||||
// Output the total number of records updated
|
||||
$this->info("Location fields migration updated for: " . $updatedRecordsCount);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error("Location fields migration failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Migrate user about field
|
||||
$tables = ['users'];
|
||||
foreach ($tables as $tableName) {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$affectedRows = DB::affectingStatement("
|
||||
UPDATE `{$destinationDb}`.`{$tableName}` dest
|
||||
JOIN (
|
||||
SELECT
|
||||
c.member_id,
|
||||
c.string_value AS about
|
||||
FROM `{$sourceDb}`.`custom_field_values` c
|
||||
WHERE c.field_id = 17
|
||||
) src ON dest.cyclos_id = src.member_id
|
||||
SET dest.about = src.about
|
||||
");
|
||||
DB::commit();
|
||||
$this->info(ucfirst($tableName) . " about field updated for $affectedRows records");
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(ucfirst($tableName) . " about field migration failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Migrate motivation field
|
||||
$tables = ['users', 'organizations', 'banks'];
|
||||
foreach ($tables as $tableName) {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$affectedRows = DB::affectingStatement("
|
||||
UPDATE `{$destinationDb}`.`{$tableName}` dest
|
||||
JOIN (
|
||||
SELECT
|
||||
c.member_id,
|
||||
c.string_value AS motivation
|
||||
FROM `{$sourceDb}`.`custom_field_values` c
|
||||
WHERE c.field_id = 35
|
||||
) src ON dest.cyclos_id = src.member_id
|
||||
SET dest.motivation = src.motivation
|
||||
");
|
||||
DB::commit();
|
||||
$this->info(ucfirst($tableName) . " motivation field updated for $affectedRows records");
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(ucfirst($tableName) . " motivation field migration failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate website field
|
||||
$tables = ['users', 'organizations', 'banks'];
|
||||
foreach ($tables as $tableName) {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$affectedRows = DB::affectingStatement("
|
||||
UPDATE `{$destinationDb}`.`{$tableName}` dest
|
||||
JOIN (
|
||||
SELECT
|
||||
c.member_id,
|
||||
c.string_value AS website
|
||||
FROM `{$sourceDb}`.`custom_field_values` c
|
||||
WHERE c.field_id = 10
|
||||
) src ON dest.cyclos_id = src.member_id
|
||||
SET dest.website = src.website
|
||||
");
|
||||
DB::commit();
|
||||
$this->info(ucfirst($tableName) . " website field updated for $affectedRows records");
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(ucfirst($tableName) . " website field migration failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Migrate birthday field
|
||||
$tables = ['users'];
|
||||
foreach ($tables as $tableName) {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$affectedRows = DB::affectingStatement("
|
||||
UPDATE `{$destinationDb}`.`{$tableName}` dest
|
||||
JOIN (
|
||||
SELECT
|
||||
c.member_id,
|
||||
STR_TO_DATE(REPLACE(c.string_value, '/', '-'), '%d-%m-%Y') AS birthday
|
||||
FROM `{$sourceDb}`.`custom_field_values` c
|
||||
WHERE c.field_id = 1
|
||||
) src ON dest.cyclos_id = src.member_id
|
||||
SET dest.date_of_birth = src.birthday
|
||||
");
|
||||
DB::commit();
|
||||
$this->info(ucfirst($tableName) . " birthday field updated for $affectedRows records");
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(ucfirst($tableName) . " birthday field migration failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate General Newsletter field to message_settings table
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// Get all newsletter preferences from Cyclos
|
||||
$newsletterPrefs = DB::table("{$sourceDb}.custom_field_values")
|
||||
->where('field_id', 28)
|
||||
->get(['member_id', 'possible_value_id']);
|
||||
|
||||
$totalUpdated = 0;
|
||||
$tables = [
|
||||
'users' => User::class,
|
||||
'organizations' => Organization::class,
|
||||
'banks' => Bank::class
|
||||
];
|
||||
|
||||
foreach ($tables as $tableName => $modelClass) {
|
||||
foreach ($newsletterPrefs as $pref) {
|
||||
$entity = DB::table($tableName)
|
||||
->where('cyclos_id', $pref->member_id)
|
||||
->first();
|
||||
|
||||
if ($entity) {
|
||||
$model = $modelClass::find($entity->id);
|
||||
if ($model) {
|
||||
// Convert: 790 (No) → 0, 791 (Yes) → 1, null → 1
|
||||
$value = $pref->possible_value_id == 790 ? 0 : 1;
|
||||
|
||||
// Update or create message settings
|
||||
$model->message_settings()->updateOrCreate(
|
||||
['message_settingable_id' => $model->id, 'message_settingable_type' => $modelClass],
|
||||
['general_newsletter' => $value]
|
||||
);
|
||||
$totalUpdated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
$this->info("General newsletter field migrated to message_settings for {$totalUpdated} records");
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error("General newsletter field migration failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Migrate Local Newsletter field to message_settings table
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// Get all newsletter preferences from Cyclos
|
||||
$newsletterPrefs = DB::table("{$sourceDb}.custom_field_values")
|
||||
->where('field_id', 29)
|
||||
->get(['member_id', 'possible_value_id']);
|
||||
|
||||
$totalUpdated = 0;
|
||||
$tables = [
|
||||
'users' => User::class,
|
||||
'organizations' => Organization::class,
|
||||
'banks' => Bank::class
|
||||
];
|
||||
|
||||
foreach ($tables as $tableName => $modelClass) {
|
||||
foreach ($newsletterPrefs as $pref) {
|
||||
$entity = DB::table($tableName)
|
||||
->where('cyclos_id', $pref->member_id)
|
||||
->first();
|
||||
|
||||
if ($entity) {
|
||||
$model = $modelClass::find($entity->id);
|
||||
if ($model) {
|
||||
// Convert: 792 (No) → 0, 793 (Yes) → 1, null → 1
|
||||
$value = $pref->possible_value_id == 792 ? 0 : 1;
|
||||
|
||||
// Update or create message settings
|
||||
$model->message_settings()->updateOrCreate(
|
||||
['message_settingable_id' => $model->id, 'message_settingable_type' => $modelClass],
|
||||
['local_newsletter' => $value]
|
||||
);
|
||||
$totalUpdated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
$this->info("Local newsletter field migrated to message_settings for {$totalUpdated} records");
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error("Local newsletter field migration failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
|
||||
// Migrate Cyclos skills from refined database
|
||||
$tables = ['users', 'organizations'];
|
||||
foreach ($tables as $tableName) {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$affectedRows = DB::affectingStatement("
|
||||
UPDATE `{$destinationDb}`.`{$tableName}` dest
|
||||
JOIN (
|
||||
SELECT
|
||||
c.member_id,
|
||||
CASE
|
||||
WHEN CHAR_LENGTH(c.string_value) > 495
|
||||
THEN CONCAT(LEFT(c.string_value, 495), ' ...')
|
||||
ELSE c.string_value
|
||||
END AS cyclos_skills
|
||||
FROM `{$sourceDb}`.`custom_field_values` c
|
||||
WHERE c.field_id = 13
|
||||
) src ON dest.cyclos_id = src.member_id
|
||||
SET dest.cyclos_skills = src.cyclos_skills
|
||||
");
|
||||
DB::commit();
|
||||
$this->info(ucfirst($tableName) . " skills field updated for $affectedRows records");
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(ucfirst($tableName) . " skills field migration failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Strip all HTML tags from imported tables
|
||||
foreach ($tables as $tableName) {
|
||||
$records = DB::table($tableName)->select('id', 'cyclos_skills')->whereNotNull('cyclos_skills')->get();
|
||||
foreach ($records as $record) {
|
||||
$cleaned = strip_tags($record->cyclos_skills);
|
||||
if ($cleaned !== $record->cyclos_skills) {
|
||||
DB::table($tableName)->where('id', $record->id)->update(['cyclos_skills' => $cleaned]);
|
||||
}
|
||||
}
|
||||
$records = DB::table($tableName)->select('id', 'about')->whereNotNull('about')->get();
|
||||
foreach ($records as $record) {
|
||||
$cleaned = strip_tags($record->about);
|
||||
if ($cleaned !== $record->about) {
|
||||
DB::table($tableName)->where('id', $record->id)->update(['about' => $cleaned]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set suspicious robot members to inactive
|
||||
|
||||
// 1755
|
||||
// 1768
|
||||
// 1776
|
||||
// 1777
|
||||
|
||||
// Check if user.about is null, if true, copy skill tags where length > 50 to user.about
|
||||
// if user.about <> null, copy skill tags where length > 50 to about_short or update this field
|
||||
|
||||
protected function migrateLocationData($modelClass, $destinationDb, $sourceDb, $countryCodeMap, $cityCodeMap)
|
||||
{
|
||||
$fullyQualifiedModelClass = "App\\Models\\" . $modelClass;
|
||||
|
||||
$cyclos_countries = DB::table("{$sourceDb}.custom_field_values")
|
||||
->where('field_id', 36)
|
||||
->get(['possible_value_id', 'member_id']);
|
||||
$cyclos_cities = DB::table("{$sourceDb}.custom_field_values")
|
||||
->where('field_id', 38)
|
||||
->get(['possible_value_id', 'member_id']);
|
||||
|
||||
$remappedCountries = $cyclos_countries->mapWithKeys(function ($item) use ($countryCodeMap) {
|
||||
return [$item->member_id => $countryCodeMap[$item->possible_value_id] ?? null];
|
||||
});
|
||||
$remappedCities = $cyclos_cities->mapWithKeys(function ($item) use ($cityCodeMap) {
|
||||
return [$item->member_id => $cityCodeMap[$item->possible_value_id] ?? null];
|
||||
});
|
||||
|
||||
$recordUpdateCount = 0;
|
||||
$syncedDataCount = 0;
|
||||
|
||||
foreach ($remappedCountries as $memberId => $countryId) {
|
||||
$cityId = $remappedCities[$memberId] ?? null;
|
||||
if ($countryId !== null || $cityId !== null) {
|
||||
$entity = DB::table("{$destinationDb}." . strtolower($modelClass) . "s")
|
||||
->where('cyclos_id', $memberId)
|
||||
->first();
|
||||
|
||||
if ($entity) {
|
||||
$entityModel = $fullyQualifiedModelClass::find($entity->id);
|
||||
if ($entityModel) {
|
||||
$location = new Location();
|
||||
$location->name = 'Default location';
|
||||
$location->country_id = $countryId;
|
||||
$location->city_id = $cityId;
|
||||
$entityModel->locations()->save($location);
|
||||
$recordUpdateCount++;
|
||||
|
||||
// Sync all missing location data (divisions, etc.)
|
||||
try {
|
||||
$synced = $location->syncAllLocationData();
|
||||
if (!empty($synced)) {
|
||||
$syncedDataCount++;
|
||||
$this->info(" → Synced data for {$modelClass} ID {$entity->id}: " . implode(', ', $synced));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->warn(" → Failed to sync location data for {$modelClass} ID {$entity->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("{$modelClass}: {$recordUpdateCount} locations created, {$syncedDataCount} had additional data synced");
|
||||
return $recordUpdateCount;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Tinker script to clean 'about' field containing only a single double quote
|
||||
*
|
||||
* Run this in Laravel Tinker:
|
||||
* php artisan tinker
|
||||
* Then paste this code
|
||||
*/
|
||||
protected function cleanAboutField()
|
||||
{
|
||||
echo "Starting cleanup of 'about' fields containing only double quotes...\n\n";
|
||||
$models = [
|
||||
'App\Models\User' => 'Users',
|
||||
'App\Models\Organization' => 'Organizations',
|
||||
'App\Models\Bank' => 'Banks',
|
||||
'App\Models\Admin' => 'Admins'
|
||||
];
|
||||
$totalUpdated = 0;
|
||||
foreach ($models as $modelClass => $tableName) {
|
||||
echo "Processing {$tableName}...\n";
|
||||
|
||||
// Check if the model class exists
|
||||
if (!class_exists($modelClass)) {
|
||||
echo " - Model {$modelClass} not found, skipping\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Find records where about field contains only a double quote
|
||||
$records = $modelClass::where('about', '"')->get();
|
||||
|
||||
echo " - Found {$records->count()} records with about = '\"'\n";
|
||||
|
||||
if ($records->count() > 0) {
|
||||
// Update records to set about to null
|
||||
$updated = $modelClass::where('about', '"')->update(['about' => null]);
|
||||
|
||||
echo " - Updated {$updated} records\n";
|
||||
$totalUpdated += $updated;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
echo " - Error processing {$tableName}: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
echo "Cleanup completed!\n";
|
||||
echo "Total records updated: {$totalUpdated}\n";
|
||||
echo "\nTo verify the cleanup, you can run:\n";
|
||||
foreach ($models as $modelClass => $tableName) {
|
||||
if (class_exists($modelClass)) {
|
||||
echo "{$modelClass}::where('about', '\"')->count(); // Should return 0\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
855
app/Console/Commands/MigrateOrganizationToUserCommand.php
Normal file
855
app/Console/Commands/MigrateOrganizationToUserCommand.php
Normal file
@@ -0,0 +1,855 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Migrate Organization to User Command
|
||||
*
|
||||
* This command safely migrates an Organization model to a User model while preserving
|
||||
* all associated relationships, accounts, and data.
|
||||
*
|
||||
* IMPORTANT: This is a one-way migration that cannot be easily reversed.
|
||||
* Always run with --dry-run first to preview changes.
|
||||
*
|
||||
* The migration process:
|
||||
* 1. Creates a new User with all Organization data
|
||||
* 2. Updates all polymorphic relationships to point to the new User
|
||||
* 3. Migrates pivot table relationships (bank management, etc.)
|
||||
* 4. Updates permission system references
|
||||
* 5. Re-indexes Elasticsearch and clears caches
|
||||
* 6. Deletes the original Organization and cleanup relationships
|
||||
*
|
||||
* Safety validations prevent migration of:
|
||||
* - Organizations with conflicting names/emails
|
||||
* - Organizations currently managing critical resources
|
||||
*
|
||||
* @author Claude Code
|
||||
* @version 1.0
|
||||
*/
|
||||
class MigrateOrganizationToUserCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'migrate:organization-to-user {organization_id} {--dry-run : Preview changes without executing them}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Migrate an Organization model to a User model while preserving all relationships and data';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$organizationId = $this->argument('organization_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('DRY RUN MODE - No changes will be made');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// Validate organization exists
|
||||
$organization = Organization::find($organizationId);
|
||||
if (!$organization) {
|
||||
$this->error("Organization with ID {$organizationId} not found");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Safety validations
|
||||
$validationResult = $this->validateOrganizationForMigration($organization);
|
||||
|
||||
// Handle blocking errors (cannot proceed)
|
||||
if (!empty($validationResult['blocking_errors'])) {
|
||||
$this->error("Migration validation failed:");
|
||||
foreach ($validationResult['blocking_errors'] as $error) {
|
||||
$this->line(" • {$error}");
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Handle warnings that require confirmation
|
||||
if (!empty($validationResult['warnings'])) {
|
||||
$this->warn("Migration warnings:");
|
||||
foreach ($validationResult['warnings'] as $warning) {
|
||||
$this->line(" • {$warning}");
|
||||
}
|
||||
|
||||
if (!$dryRun && !$this->confirm('Do you want to continue with the migration despite these warnings?', false)) {
|
||||
$this->info('Migration cancelled by user.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle specific management conflicts
|
||||
if (!empty($validationResult['management_conflicts'])) {
|
||||
$this->warn("Management Conflicts:");
|
||||
foreach ($validationResult['management_conflicts'] as $conflict) {
|
||||
$this->line(" • {$conflict}");
|
||||
}
|
||||
|
||||
if (!$dryRun && !$this->confirm('Do you still want to migrate? All management relationships will be removed.', false)) {
|
||||
$this->info('Migration cancelled by user.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Migrating Organization '{$organization->name}' (ID: {$organizationId}) to User");
|
||||
$this->newLine();
|
||||
|
||||
if ($dryRun) {
|
||||
return $this->previewMigration($organization);
|
||||
}
|
||||
|
||||
return $this->executeMigration($organization);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview what the migration would do
|
||||
*/
|
||||
private function previewMigration(Organization $organization): int
|
||||
{
|
||||
$this->info('MIGRATION PREVIEW:');
|
||||
$this->line('─────────────────────');
|
||||
|
||||
// Check what would be created
|
||||
$this->info("Would create User:");
|
||||
$this->line(" Name: {$organization->name}");
|
||||
$this->line(" Email: {$organization->email}");
|
||||
$this->line(" Limits: min=" . timebank_config('accounts.user.limit_min', 0) .
|
||||
", max=" . timebank_config('accounts.user.limit_max', 6000));
|
||||
|
||||
// Show management cleanup that would happen
|
||||
try {
|
||||
if (method_exists($organization, 'banksManaged')) {
|
||||
$bankCount = $organization->banksManaged()->count();
|
||||
if ($bankCount > 0) {
|
||||
$bankNames = $organization->banksManaged()->pluck('name')->toArray();
|
||||
$this->line("Would remove management of {$bankCount} bank(s):");
|
||||
foreach ($bankNames as $bankName) {
|
||||
$this->line(" - {$bankName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Skip bank management preview if relationship doesn't exist
|
||||
}
|
||||
|
||||
// Check polymorphic relationships
|
||||
$polymorphicTables = $this->getPolymorphicTables();
|
||||
foreach ($polymorphicTables as $table => $columns) {
|
||||
$count = DB::table($table)
|
||||
->where($columns['type'], 'App\Models\Organization')
|
||||
->where($columns['id'], $organization->id)
|
||||
->count();
|
||||
|
||||
if ($count > 0) {
|
||||
$this->line("{$table}: {$count} records to update");
|
||||
}
|
||||
}
|
||||
|
||||
// Check pivot tables
|
||||
$pivotTables = $this->getPivotTables();
|
||||
foreach ($pivotTables as $table => $column) {
|
||||
if ($this->tableExists($table)) {
|
||||
$count = DB::table($table)->where($column, $organization->id)->count();
|
||||
if ($count > 0) {
|
||||
$this->line("{$table}: {$count} records to migrate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Run without --dry-run to execute the migration');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual migration
|
||||
*/
|
||||
private function executeMigration(Organization $organization): int
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$this->info('Starting migration...');
|
||||
|
||||
// Step 1: Remove management relationships
|
||||
$this->removeManagementRelationships($organization);
|
||||
|
||||
// Step 2: Create User
|
||||
$user = $this->createUserFromOrganization($organization);
|
||||
$this->info("Created User with ID: {$user->id}");
|
||||
|
||||
// Step 3: Update polymorphic relationships
|
||||
$this->updatePolymorphicRelationships($organization, $user);
|
||||
|
||||
// Step 4: Update love package relationships
|
||||
$this->updateLoveRelationships($organization, $user);
|
||||
|
||||
// Step 5: Handle pivot tables
|
||||
$this->updatePivotTables($organization, $user);
|
||||
|
||||
// Step 6: Update direct references
|
||||
$this->updateDirectReferences($organization, $user);
|
||||
|
||||
// Step 7: Handle special cases
|
||||
$this->handleSpecialCases($organization, $user);
|
||||
|
||||
// Step 8: Delete the original Organization and cleanup relationships
|
||||
$this->deleteOrganizationAndRelationships($organization, $user);
|
||||
|
||||
DB::commit();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Migration completed successfully!');
|
||||
$this->info("Organization ID {$organization->id} is now User ID {$user->id}");
|
||||
$this->info('Original Organization record and all relationships deleted');
|
||||
|
||||
return 0;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error('Migration failed: ' . $e->getMessage());
|
||||
Log::error('Organization to User migration failed', [
|
||||
'organization_id' => $organization->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create User from Organization data
|
||||
*/
|
||||
private function createUserFromOrganization(Organization $organization): User
|
||||
{
|
||||
// Copy all common columns between Organization and User models
|
||||
$userData = $organization->only([
|
||||
'name', 'full_name', 'email', 'profile_photo_path',
|
||||
'about', 'about_short', 'motivation', 'website',
|
||||
'phone', 'phone_public', 'password', 'lang_preference',
|
||||
'last_login_at', 'last_login_ip', 'comment', 'cyclos_id',
|
||||
'cyclos_salt', 'cyclos_skills', 'email_verified_at',
|
||||
'inactive_at', 'deleted_at'
|
||||
]);
|
||||
|
||||
// Set User-specific limits from config
|
||||
$userData['limit_min'] = timebank_config('profiles.user.limit_min', 0);
|
||||
$userData['limit_max'] = timebank_config('profiles.user.limit_max', 6000);
|
||||
|
||||
// Copy timestamps
|
||||
$userData['created_at'] = $organization->created_at;
|
||||
$userData['updated_at'] = $organization->updated_at;
|
||||
|
||||
// Create the user with fillable fields (don't include love IDs yet)
|
||||
$user = User::create($userData);
|
||||
|
||||
// Set non-fillable fields directly on the model to bypass mass assignment protection
|
||||
$nonFillableFields = [
|
||||
'comment', 'cyclos_id', 'cyclos_salt', 'cyclos_skills',
|
||||
'email_verified_at', 'inactive_at', 'deleted_at'
|
||||
];
|
||||
|
||||
foreach ($nonFillableFields as $field) {
|
||||
if ($organization->$field !== null) {
|
||||
$user->$field = $organization->$field;
|
||||
}
|
||||
}
|
||||
|
||||
// Force copy love package IDs from organization AFTER user creation
|
||||
// This ensures we preserve the original organization's love IDs, not newly generated ones
|
||||
if ($organization->love_reactant_id) {
|
||||
$user->love_reactant_id = $organization->love_reactant_id;
|
||||
}
|
||||
if ($organization->love_reacter_id) {
|
||||
$user->love_reacter_id = $organization->love_reacter_id;
|
||||
}
|
||||
|
||||
// Save the user with all additional fields including love IDs
|
||||
$user->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all polymorphic relationships
|
||||
*/
|
||||
private function updatePolymorphicRelationships(Organization $organization, User $user): void
|
||||
{
|
||||
$this->info('Updating polymorphic relationships...');
|
||||
|
||||
$polymorphicTables = $this->getPolymorphicTables();
|
||||
|
||||
foreach ($polymorphicTables as $table => $columns) {
|
||||
$count = DB::table($table)
|
||||
->where($columns['type'], 'App\Models\Organization')
|
||||
->where($columns['id'], $organization->id)
|
||||
->update([
|
||||
$columns['type'] => 'App\Models\User',
|
||||
$columns['id'] => $user->id
|
||||
]);
|
||||
|
||||
if ($count > 0) {
|
||||
$this->line(" {$table}: Updated {$count} records");
|
||||
}
|
||||
}
|
||||
|
||||
// Update account limits specifically
|
||||
$this->updateAccountLimits($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update account limits to user values
|
||||
*/
|
||||
private function updateAccountLimits(User $user): void
|
||||
{
|
||||
$this->info('Updating account limits and names...');
|
||||
|
||||
$orgAccountName = timebank_config('accounts.organization.name', 'organization');
|
||||
$userAccountName = timebank_config('accounts.user.name', 'personal');
|
||||
$limitMin = timebank_config('accounts.user.limit_min', 0);
|
||||
$limitMax = timebank_config('accounts.user.limit_max', 6000);
|
||||
|
||||
// Get all accounts for this user
|
||||
$accounts = DB::table('accounts')
|
||||
->where('accountable_type', 'App\Models\User')
|
||||
->where('accountable_id', $user->id)
|
||||
->get(['id', 'name']);
|
||||
|
||||
$limitsUpdated = 0;
|
||||
$namesRenamed = 0;
|
||||
$assignedNames = []; // Track names assigned during this migration
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$updateData = [
|
||||
'limit_min' => $limitMin,
|
||||
'limit_max' => $limitMax
|
||||
];
|
||||
|
||||
// Rename accounts based on their current name (reverse of user-to-org)
|
||||
if ($account->name === $orgAccountName || preg_match('/^' . preg_quote($orgAccountName, '/') . ' \d+$/', $account->name)) {
|
||||
// Rename 'organization' or 'organization 2', etc. to 'personal' (with numbering if needed)
|
||||
$newName = $this->generateUniqueAccountName($user, $userAccountName, $assignedNames);
|
||||
$updateData['name'] = $newName;
|
||||
$assignedNames[] = $newName; // Track this assigned name
|
||||
$namesRenamed++;
|
||||
$this->line(" Renaming account ID {$account->id}: '{$account->name}' → '{$newName}'");
|
||||
} elseif ($account->name === 'donation' || preg_match('/^donation \d+$/', $account->name)) {
|
||||
// Rename 'donation' or 'donation 2', etc. to 'gift' (with numbering if needed)
|
||||
$newName = $this->generateUniqueAccountName($user, 'gift', $assignedNames);
|
||||
$updateData['name'] = $newName;
|
||||
$assignedNames[] = $newName; // Track this assigned name
|
||||
$namesRenamed++;
|
||||
$this->line(" Renaming account ID {$account->id}: '{$account->name}' → '{$newName}'");
|
||||
}
|
||||
|
||||
// Update the account
|
||||
DB::table('accounts')
|
||||
->where('id', $account->id)
|
||||
->update($updateData);
|
||||
|
||||
$limitsUpdated++;
|
||||
}
|
||||
|
||||
if ($limitsUpdated > 0) {
|
||||
$this->line(" Updated limits for {$limitsUpdated} account(s): min={$limitMin}, max={$limitMax}");
|
||||
}
|
||||
|
||||
if ($namesRenamed > 0) {
|
||||
$this->line(" Renamed {$namesRenamed} account(s) (organization→personal, donation→gift)");
|
||||
}
|
||||
|
||||
if ($limitsUpdated === 0) {
|
||||
$this->line(" No accounts found to update");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique account name for the user
|
||||
*/
|
||||
private function generateUniqueAccountName(User $user, string $baseName, array $accountsBeingRenamed = []): string
|
||||
{
|
||||
// Get all existing account names for this user
|
||||
$existingNames = DB::table('accounts')
|
||||
->where('accountable_type', 'App\Models\User')
|
||||
->where('accountable_id', $user->id)
|
||||
->pluck('name')
|
||||
->toArray();
|
||||
|
||||
// Also consider names that are being assigned in this migration batch
|
||||
$existingNames = array_merge($existingNames, $accountsBeingRenamed);
|
||||
|
||||
// If base name doesn't exist, use it
|
||||
if (!in_array($baseName, $existingNames)) {
|
||||
return $baseName;
|
||||
}
|
||||
|
||||
// Try numbered versions until we find one that doesn't exist
|
||||
$counter = 2;
|
||||
while (true) {
|
||||
$candidateName = $baseName . ' ' . $counter;
|
||||
if (!in_array($candidateName, $existingNames)) {
|
||||
return $candidateName;
|
||||
}
|
||||
$counter++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update love package relationships
|
||||
*/
|
||||
private function updateLoveRelationships(Organization $organization, User $user): void
|
||||
{
|
||||
$this->info('Updating love package relationships...');
|
||||
|
||||
// Update love_reactants type from Organization to User
|
||||
if ($organization->loveReactant) {
|
||||
$updated = DB::table('love_reactants')
|
||||
->where('id', $organization->loveReactant->id)
|
||||
->update(['type' => 'App\Models\User']);
|
||||
|
||||
if ($updated > 0) {
|
||||
$this->line(" love_reactants: Updated type for reactant ID {$organization->loveReactant->id}");
|
||||
}
|
||||
}
|
||||
|
||||
// Update love_reacters type from Organization to User
|
||||
if ($organization->loveReacter) {
|
||||
$updated = DB::table('love_reacters')
|
||||
->where('id', $organization->loveReacter->id)
|
||||
->update(['type' => 'App\Models\User']);
|
||||
|
||||
if ($updated > 0) {
|
||||
$this->line(" love_reacters: Updated type for reacter ID {$organization->loveReacter->id}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!$organization->loveReactant && !$organization->loveReacter) {
|
||||
$this->line(" No love relationships to update");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pivot table migrations
|
||||
*/
|
||||
private function updatePivotTables(Organization $organization, User $user): void
|
||||
{
|
||||
$this->info(' Updating pivot tables...');
|
||||
|
||||
// Handle bank_organization -> bank_user migration (if table exists)
|
||||
if ($this->tableExists('bank_organization')) {
|
||||
$bankRelationships = DB::table('bank_organization')->where('organization_id', $organization->id)->get();
|
||||
foreach ($bankRelationships as $relationship) {
|
||||
// Create new bank_user relationship
|
||||
DB::table('bank_user')->insertOrIgnore([
|
||||
'bank_id' => $relationship->bank_id,
|
||||
'user_id' => $user->id,
|
||||
'created_at' => $relationship->created_at ?? now(),
|
||||
'updated_at' => $relationship->updated_at ?? now()
|
||||
]);
|
||||
}
|
||||
|
||||
if ($bankRelationships->count() > 0) {
|
||||
$this->line(" bank_user: Migrated {$bankRelationships->count()} relationships");
|
||||
// Delete old bank_organization relationships
|
||||
DB::table('bank_organization')->where('organization_id', $organization->id)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other pivot tables
|
||||
$pivotTables = $this->getPivotTables();
|
||||
foreach ($pivotTables as $table => $column) {
|
||||
if ($table === 'bank_organization') {
|
||||
continue;
|
||||
} // Already handled above
|
||||
|
||||
if ($this->tableExists($table)) {
|
||||
$count = DB::table($table)->where($column, $organization->id)->count();
|
||||
if ($count > 0) {
|
||||
$this->line(" {$table}: {$count} records need manual review");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update direct references
|
||||
*/
|
||||
private function updateDirectReferences(Organization $organization, User $user): void
|
||||
{
|
||||
$this->info(' Updating direct references...');
|
||||
|
||||
// Handle Spatie Permission tables
|
||||
DB::table('model_has_roles')
|
||||
->where('model_type', 'App\Models\Organization')
|
||||
->where('model_id', $organization->id)
|
||||
->update([
|
||||
'model_type' => 'App\Models\User',
|
||||
'model_id' => $user->id
|
||||
]);
|
||||
|
||||
DB::table('model_has_permissions')
|
||||
->where('model_type', 'App\Models\Organization')
|
||||
->where('model_id', $organization->id)
|
||||
->update([
|
||||
'model_type' => 'App\Models\User',
|
||||
'model_id' => $user->id
|
||||
]);
|
||||
|
||||
$this->line(" Updated permission system references");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle special cases like Elasticsearch, caches, etc.
|
||||
*/
|
||||
private function handleSpecialCases(Organization $organization, User $user): void
|
||||
{
|
||||
$this->info(' Handling special cases...');
|
||||
|
||||
// Re-index in Elasticsearch
|
||||
try {
|
||||
$user->searchable();
|
||||
$this->line(" Updated Elasticsearch index");
|
||||
} catch (\Exception $e) {
|
||||
$this->line(" Elasticsearch update failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Clear caches
|
||||
if (function_exists('cache')) {
|
||||
cache()->forget("organization.{$organization->id}");
|
||||
$this->line(" Cleared organization cache");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get polymorphic table mappings
|
||||
*/
|
||||
private function getPolymorphicTables(): array
|
||||
{
|
||||
$tables = [
|
||||
'accounts' => ['type' => 'accountable_type', 'id' => 'accountable_id'],
|
||||
'locations' => ['type' => 'locatable_type', 'id' => 'locatable_id'],
|
||||
'posts' => ['type' => 'postable_type', 'id' => 'postable_id'],
|
||||
'categories' => ['type' => 'categoryable_type', 'id' => 'categoryable_id'],
|
||||
'activity_log' => ['type' => 'subject_type', 'id' => 'subject_id'],
|
||||
];
|
||||
|
||||
// Check for optional tables that might exist
|
||||
if ($this->tableExists('languagables')) {
|
||||
$tables['languagables'] = ['type' => 'languagable_type', 'id' => 'languagable_id'];
|
||||
}
|
||||
|
||||
if ($this->tableExists('sociables')) {
|
||||
$tables['sociables'] = ['type' => 'sociable_type', 'id' => 'sociable_id'];
|
||||
}
|
||||
|
||||
if ($this->tableExists('bank_clients')) {
|
||||
$tables['bank_clients'] = ['type' => 'client_type', 'id' => 'client_id'];
|
||||
}
|
||||
|
||||
// Love package tables are handled separately in updateLoveRelationships()
|
||||
// as they use a different pattern (type column instead of polymorphic columns)
|
||||
|
||||
return $tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pivot table mappings
|
||||
*/
|
||||
private function getPivotTables(): array
|
||||
{
|
||||
return [
|
||||
'bank_organization' => 'organization_id',
|
||||
'organization_user' => 'organization_id',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a table exists
|
||||
*/
|
||||
private function tableExists(string $tableName): bool
|
||||
{
|
||||
try {
|
||||
DB::table($tableName)->limit(1)->count();
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if organization can be safely migrated
|
||||
*/
|
||||
private function validateOrganizationForMigration(Organization $organization): array
|
||||
{
|
||||
$blockingErrors = [];
|
||||
$warnings = [];
|
||||
|
||||
// BLOCKING ERRORS - Migration cannot proceed
|
||||
|
||||
// Check if a user with the same name already exists
|
||||
if (User::where('name', $organization->name)->exists()) {
|
||||
$blockingErrors[] = "A User with name '{$organization->name}' already exists";
|
||||
}
|
||||
|
||||
// Check if a user with the same email already exists
|
||||
if (User::where('email', $organization->email)->exists()) {
|
||||
$blockingErrors[] = "A User with email '{$organization->email}' already exists";
|
||||
}
|
||||
|
||||
// WARNINGS - Migration can proceed with confirmation
|
||||
|
||||
// Check if organization has high-value accounts
|
||||
$highValueAccount = $organization->accounts()->where('limit_max', '>', 6000)->first();
|
||||
if ($highValueAccount) {
|
||||
$warnings[] = "Organization has account(s) with limits higher than user maximum (6000) - limits will be reduced";
|
||||
}
|
||||
|
||||
// Check if organization has many accounts (might be complex)
|
||||
$accountCount = $organization->accounts()->count();
|
||||
if ($accountCount > 3) {
|
||||
$warnings[] = "Organization has {$accountCount} accounts - this might be a complex business organization";
|
||||
}
|
||||
|
||||
// MANAGEMENT CONFLICTS - Separate from general warnings
|
||||
$managementConflicts = [];
|
||||
|
||||
// Check if organization is managing banks (if the relationship exists)
|
||||
try {
|
||||
if (method_exists($organization, 'banksManaged') && $organization->banksManaged()->count() > 0) {
|
||||
$managementConflicts[] = "Organization is managing " . $organization->banksManaged()->count() . " bank(s). After the migration this will not be possible any more.";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Skip bank management check if relationship doesn't exist or table is missing
|
||||
}
|
||||
|
||||
return [
|
||||
'blocking_errors' => $blockingErrors,
|
||||
'warnings' => $warnings,
|
||||
'management_conflicts' => $managementConflicts
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove management relationships before migration
|
||||
*/
|
||||
private function removeManagementRelationships(Organization $organization): void
|
||||
{
|
||||
$this->info(' Removing management relationships...');
|
||||
|
||||
$bankCount = 0;
|
||||
|
||||
// Remove bank management relationships (if they exist)
|
||||
try {
|
||||
if (method_exists($organization, 'banksManaged')) {
|
||||
$bankCount = $organization->banksManaged()->count();
|
||||
if ($bankCount > 0) {
|
||||
$bankNames = $organization->banksManaged()->pluck('name')->toArray();
|
||||
$organization->banksManaged()->detach(); // Un-associate all managed banks
|
||||
$this->line(" Removed management of {$bankCount} bank(s): " . implode(', ', $bankNames));
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Skip bank management removal if relationship doesn't exist
|
||||
}
|
||||
|
||||
if ($bankCount === 0) {
|
||||
$this->line(' No management relationships to remove');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Organization and cleanup all remaining relationships
|
||||
*/
|
||||
private function deleteOrganizationAndRelationships(Organization $organization, User $user): void
|
||||
{
|
||||
$this->info(' Deleting Organization and cleaning up relationships...');
|
||||
|
||||
// Step 1: Verify critical data was migrated
|
||||
$this->verifyMigrationCompleteness($organization, $user);
|
||||
|
||||
// Step 2: Clean up pivot table relationships that weren't migrated
|
||||
$this->cleanupPivotRelationships($organization);
|
||||
|
||||
// Step 3: Clean up remaining foreign key references
|
||||
$this->cleanupForeignKeyReferences($organization);
|
||||
|
||||
// Step 4: Delete the Organization model
|
||||
$organizationId = $organization->id;
|
||||
$organizationName = $organization->name;
|
||||
|
||||
$organization->delete();
|
||||
|
||||
$this->line(" Deleted Organization '{$organizationName}' (ID: {$organizationId})");
|
||||
|
||||
// Step 5: Verify complete deletion
|
||||
$this->verifyOrganizationDeletion($organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that critical data was successfully migrated
|
||||
*/
|
||||
private function verifyMigrationCompleteness(Organization $organization, User $user): void
|
||||
{
|
||||
// Check that accounts were transferred
|
||||
$orgAccounts = $organization->accounts()->count();
|
||||
$userAccounts = $user->accounts()->count();
|
||||
|
||||
if ($orgAccounts > 0) {
|
||||
throw new \Exception("Organization still has {$orgAccounts} accounts - migration incomplete");
|
||||
}
|
||||
|
||||
if ($userAccounts === 0) {
|
||||
$this->line(" User has no accounts - this may be expected");
|
||||
}
|
||||
|
||||
$this->line(" Migration verification passed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up pivot table relationships
|
||||
*/
|
||||
private function cleanupPivotRelationships(Organization $organization): void
|
||||
{
|
||||
$cleanupTables = [
|
||||
'organization_user' => 'organization_id',
|
||||
'bank_organization' => 'organization_id'
|
||||
];
|
||||
|
||||
foreach ($cleanupTables as $table => $column) {
|
||||
if ($this->tableExists($table)) {
|
||||
$count = DB::table($table)->where($column, $organization->id)->count();
|
||||
if ($count > 0) {
|
||||
DB::table($table)->where($column, $organization->id)->delete();
|
||||
$this->line(" Cleaned up {$table}: {$count} records deleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up Spatie permission pivot tables
|
||||
$permissionTables = [
|
||||
'model_has_roles' => ['model_type' => 'App\Models\Organization', 'model_id' => $organization->id],
|
||||
'model_has_permissions' => ['model_type' => 'App\Models\Organization', 'model_id' => $organization->id]
|
||||
];
|
||||
|
||||
foreach ($permissionTables as $table => $conditions) {
|
||||
if ($this->tableExists($table)) {
|
||||
$count = DB::table($table)->where($conditions)->count();
|
||||
if ($count > 0) {
|
||||
DB::table($table)->where($conditions)->delete();
|
||||
$this->line(" Cleaned up {$table}: {$count} records deleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up foreign key references
|
||||
*/
|
||||
private function cleanupForeignKeyReferences(Organization $organization): void
|
||||
{
|
||||
// Clean up activity logs where Organization is the causer (not subject - those are audit trail)
|
||||
if ($this->tableExists('activity_log')) {
|
||||
$count = DB::table('activity_log')
|
||||
->where('causer_type', 'App\Models\Organization')
|
||||
->where('causer_id', $organization->id)
|
||||
->count();
|
||||
|
||||
if ($count > 0) {
|
||||
// Set causer to null instead of deleting logs for audit trail
|
||||
DB::table('activity_log')
|
||||
->where('causer_type', 'App\Models\Organization')
|
||||
->where('causer_id', $organization->id)
|
||||
->update([
|
||||
'causer_type' => null,
|
||||
'causer_id' => null
|
||||
]);
|
||||
$this->line(" Cleaned up activity_log causers: {$count} records updated");
|
||||
}
|
||||
}
|
||||
|
||||
// Love package cleanup is handled by updateLoveRelationships() method
|
||||
// No additional cleanup needed as we're updating types, not deleting records
|
||||
|
||||
// Clean up any remaining chat/messaging relationships
|
||||
$chatTables = ['chat_participants', 'chat_messages'];
|
||||
foreach ($chatTables as $table) {
|
||||
if ($this->tableExists($table)) {
|
||||
$orgColumn = $table === 'chat_participants' ? 'organization_id' : 'sender_id';
|
||||
if ($this->tableHasColumn($table, $orgColumn)) {
|
||||
$count = DB::table($table)->where($orgColumn, $organization->id)->count();
|
||||
|
||||
if ($count > 0) {
|
||||
if ($table === 'chat_messages') {
|
||||
// For messages, mark as deleted rather than removing for chat history
|
||||
DB::table($table)
|
||||
->where($orgColumn, $organization->id)
|
||||
->update(['sender_id' => null, 'deleted_at' => now()]);
|
||||
$this->line(" Cleaned up {$table}: {$count} records marked as deleted");
|
||||
} else {
|
||||
DB::table($table)->where($orgColumn, $organization->id)->delete();
|
||||
$this->line(" Cleaned up {$table}: {$count} records deleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if table has specific column
|
||||
*/
|
||||
private function tableHasColumn(string $tableName, string $columnName): bool
|
||||
{
|
||||
try {
|
||||
return DB::getSchemaBuilder()->hasColumn($tableName, $columnName);
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Organization was completely deleted
|
||||
*/
|
||||
private function verifyOrganizationDeletion(int $organizationId): void
|
||||
{
|
||||
// Check that Organization record is gone
|
||||
if (Organization::find($organizationId)) {
|
||||
throw new \Exception("Organization deletion failed - Organization {$organizationId} still exists");
|
||||
}
|
||||
|
||||
// Check for any remaining references in key tables
|
||||
$checkTables = [
|
||||
'organization_user' => 'organization_id',
|
||||
'bank_organization' => 'organization_id'
|
||||
];
|
||||
|
||||
foreach ($checkTables as $table => $column) {
|
||||
if ($this->tableExists($table)) {
|
||||
$remaining = DB::table($table)->where($column, $organizationId)->count();
|
||||
if ($remaining > 0) {
|
||||
$this->line(" Warning: {$remaining} records remain in {$table}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(" Organization deletion verification completed");
|
||||
}
|
||||
}
|
||||
909
app/Console/Commands/MigrateUserToOrganizationCommand.php
Normal file
909
app/Console/Commands/MigrateUserToOrganizationCommand.php
Normal file
@@ -0,0 +1,909 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Migrate User to Organization Command
|
||||
*
|
||||
* This command safely migrates a User model to an Organization model while preserving
|
||||
* all associated relationships, accounts, and data.
|
||||
*
|
||||
* IMPORTANT: This is a one-way migration that cannot be easily reversed.
|
||||
* Always run with --dry-run first to preview changes.
|
||||
*
|
||||
* The migration process:
|
||||
* 1. Creates a new Organization with all User data
|
||||
* 2. Updates all polymorphic relationships to point to the new Organization
|
||||
* 3. Migrates pivot table relationships (bank management, etc.)
|
||||
* 4. Updates permission system references
|
||||
* 5. Re-indexes Elasticsearch and clears caches
|
||||
*
|
||||
* Safety validations prevent migration of:
|
||||
* - Super Admin users
|
||||
* - Users with critical system permissions
|
||||
* - Users with conflicting names/emails
|
||||
* - Users currently online
|
||||
*
|
||||
* @author Claude Code
|
||||
* @version 1.0
|
||||
*/
|
||||
class MigrateUserToOrganizationCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'migrate:user-to-organization {user_id} {--dry-run : Preview changes without executing them}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Migrate a User model to an Organization model while preserving all relationships and data';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$userId = $this->argument('user_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('DRY RUN MODE - No changes will be made');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// Validate user exists
|
||||
$user = User::find($userId);
|
||||
if (!$user) {
|
||||
$this->error("User with ID {$userId} not found");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Safety validations
|
||||
$validationResult = $this->validateUserForMigration($user);
|
||||
|
||||
// Handle blocking errors (cannot proceed)
|
||||
if (!empty($validationResult['blocking_errors'])) {
|
||||
$this->error("Migration validation failed:");
|
||||
foreach ($validationResult['blocking_errors'] as $error) {
|
||||
$this->line(" • {$error}");
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Handle warnings that require confirmation
|
||||
if (!empty($validationResult['warnings'])) {
|
||||
$this->warn("Migration warnings:");
|
||||
foreach ($validationResult['warnings'] as $warning) {
|
||||
$this->line(" • {$warning}");
|
||||
}
|
||||
|
||||
if (!$dryRun && !$this->confirm('Do you want to continue with the migration despite these warnings?', false)) {
|
||||
$this->info('Migration cancelled by user.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle specific organization management conflicts
|
||||
if (!empty($validationResult['organization_conflicts'])) {
|
||||
$this->warn("Organization Management Conflicts:");
|
||||
foreach ($validationResult['organization_conflicts'] as $conflict) {
|
||||
$this->line(" • {$conflict}");
|
||||
}
|
||||
|
||||
if (!$dryRun && !$this->confirm('Do you still want to migrate? All organization management relationships will be removed.', false)) {
|
||||
$this->info('Migration cancelled by user.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Migrating User '{$user->name}' (ID: {$userId}) to Organization");
|
||||
$this->newLine();
|
||||
|
||||
if ($dryRun) {
|
||||
return $this->previewMigration($user);
|
||||
}
|
||||
|
||||
return $this->executeMigration($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview what the migration would do
|
||||
*/
|
||||
private function previewMigration(User $user): int
|
||||
{
|
||||
$this->info('MIGRATION PREVIEW:');
|
||||
$this->line('─────────────────────');
|
||||
|
||||
// Check what would be created
|
||||
$this->info("Would create Organization:");
|
||||
$this->line(" Name: {$user->name}");
|
||||
$this->line(" Email: {$user->email}");
|
||||
$this->line(" Limits: min=" . timebank_config('accounts.organization.limit_min', 0) .
|
||||
", max=" . timebank_config('accounts.organization.limit_max', 12000));
|
||||
|
||||
// Show admin cleanup that would happen
|
||||
$adminRoles = ['admin', 'super-admin', 'Admin', 'Super Admin'];
|
||||
$hasAdminRoles = false;
|
||||
foreach ($adminRoles as $roleName) {
|
||||
if ($user->hasRole($roleName)) {
|
||||
if (!$hasAdminRoles) {
|
||||
$this->line("Would remove admin roles:");
|
||||
$hasAdminRoles = true;
|
||||
}
|
||||
$this->line(" - {$roleName}");
|
||||
}
|
||||
}
|
||||
|
||||
$adminCount = $user->admins()->count();
|
||||
if ($adminCount > 0) {
|
||||
$this->line("Would remove {$adminCount} admin relationships");
|
||||
}
|
||||
|
||||
// Show bank management cleanup that would happen
|
||||
$bankCount = $user->banksManaged()->count();
|
||||
if ($bankCount > 0) {
|
||||
$bankNames = $user->banksManaged()->pluck('name')->toArray();
|
||||
$this->line("Would remove management of {$bankCount} bank(s):");
|
||||
foreach ($bankNames as $bankName) {
|
||||
$this->line(" - {$bankName}");
|
||||
}
|
||||
}
|
||||
|
||||
// Show organization management cleanup that would happen
|
||||
$organizationCount = $user->organizations()->count();
|
||||
if ($organizationCount > 0) {
|
||||
$orgNames = $user->organizations()->pluck('name')->toArray();
|
||||
$this->line("Would remove management of {$organizationCount} organization(s):");
|
||||
foreach ($orgNames as $orgName) {
|
||||
$this->line(" - {$orgName}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check polymorphic relationships
|
||||
$polymorphicTables = $this->getPolymorphicTables();
|
||||
foreach ($polymorphicTables as $table => $columns) {
|
||||
$count = DB::table($table)
|
||||
->where($columns['type'], 'App\Models\User')
|
||||
->where($columns['id'], $user->id)
|
||||
->count();
|
||||
|
||||
if ($count > 0) {
|
||||
$this->line("{$table}: {$count} records to update");
|
||||
}
|
||||
}
|
||||
|
||||
// Check pivot tables
|
||||
$pivotTables = $this->getPivotTables();
|
||||
foreach ($pivotTables as $table => $column) {
|
||||
$count = DB::table($table)->where($column, $user->id)->count();
|
||||
if ($count > 0) {
|
||||
$this->line("{$table}: {$count} records to migrate");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Run without --dry-run to execute the migration');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual migration
|
||||
*/
|
||||
private function executeMigration(User $user): int
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$this->info('Starting migration...');
|
||||
|
||||
// Step 1: Remove admin relationships and roles
|
||||
$this->removeAdminRelationshipsAndRoles($user);
|
||||
|
||||
// Step 2: Create Organization
|
||||
$organization = $this->createOrganizationFromUser($user);
|
||||
$this->info("Created Organization with ID: {$organization->id}");
|
||||
|
||||
// Step 3: Update polymorphic relationships
|
||||
$this->updatePolymorphicRelationships($user, $organization);
|
||||
|
||||
// Step 4: Update love package relationships
|
||||
$this->updateLoveRelationships($user, $organization);
|
||||
|
||||
// Step 5: Handle pivot tables
|
||||
$this->updatePivotTables($user, $organization);
|
||||
|
||||
// Step 6: Update direct references
|
||||
$this->updateDirectReferences($user, $organization);
|
||||
|
||||
// Step 7: Handle special cases
|
||||
$this->handleSpecialCases($user, $organization);
|
||||
|
||||
// Step 8: Delete the original User and cleanup relationships
|
||||
$this->deleteUserAndRelationships($user, $organization);
|
||||
|
||||
DB::commit();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Migration completed successfully!');
|
||||
$this->info("User ID {$user->id} is now Organization ID {$organization->id}");
|
||||
$this->info('Original User record and all relationships deleted');
|
||||
|
||||
return 0;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error('Migration failed: ' . $e->getMessage());
|
||||
Log::error('User to Organization migration failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Organization from User data
|
||||
*/
|
||||
private function createOrganizationFromUser(User $user): Organization
|
||||
{
|
||||
// Copy all common columns between User and Organization models
|
||||
$organizationData = $user->only([
|
||||
'name', 'full_name', 'email', 'profile_photo_path',
|
||||
'about', 'about_short', 'motivation', 'website',
|
||||
'phone', 'phone_public', 'password', 'lang_preference',
|
||||
'last_login_at', 'last_login_ip', 'comment', 'cyclos_id',
|
||||
'cyclos_salt', 'cyclos_skills', 'email_verified_at',
|
||||
'inactive_at', 'deleted_at'
|
||||
]);
|
||||
|
||||
// Set Organization-specific limits from config
|
||||
$organizationData['limit_min'] = timebank_config('profiles.organization.limit_min', 0);
|
||||
$organizationData['limit_max'] = timebank_config('profiles.organization.limit_max', 6000);
|
||||
|
||||
// Copy timestamps
|
||||
$organizationData['created_at'] = $user->created_at;
|
||||
$organizationData['updated_at'] = $user->updated_at;
|
||||
|
||||
// Create the organization with fillable fields (don't include love IDs yet)
|
||||
$organization = Organization::create($organizationData);
|
||||
|
||||
// Set non-fillable fields directly on the model to bypass mass assignment protection
|
||||
$nonFillableFields = [
|
||||
'comment', 'cyclos_id', 'cyclos_salt', 'cyclos_skills',
|
||||
'email_verified_at', 'inactive_at', 'deleted_at'
|
||||
];
|
||||
|
||||
foreach ($nonFillableFields as $field) {
|
||||
if ($user->$field !== null) {
|
||||
$organization->$field = $user->$field;
|
||||
}
|
||||
}
|
||||
|
||||
// Force copy love package IDs from user AFTER organization creation
|
||||
// This ensures we preserve the original user's love IDs, not newly generated ones
|
||||
if ($user->love_reactant_id) {
|
||||
$organization->love_reactant_id = $user->love_reactant_id;
|
||||
}
|
||||
if ($user->love_reacter_id) {
|
||||
$organization->love_reacter_id = $user->love_reacter_id;
|
||||
}
|
||||
|
||||
// Save the organization with all additional fields including love IDs
|
||||
$organization->save();
|
||||
|
||||
return $organization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all polymorphic relationships
|
||||
*/
|
||||
private function updatePolymorphicRelationships(User $user, Organization $organization): void
|
||||
{
|
||||
$this->info('Updating polymorphic relationships...');
|
||||
|
||||
$polymorphicTables = $this->getPolymorphicTables();
|
||||
|
||||
foreach ($polymorphicTables as $table => $columns) {
|
||||
$count = DB::table($table)
|
||||
->where($columns['type'], 'App\Models\User')
|
||||
->where($columns['id'], $user->id)
|
||||
->update([
|
||||
$columns['type'] => 'App\Models\Organization',
|
||||
$columns['id'] => $organization->id
|
||||
]);
|
||||
|
||||
if ($count > 0) {
|
||||
$this->line(" {$table}: Updated {$count} records");
|
||||
}
|
||||
}
|
||||
|
||||
// Update account limits specifically
|
||||
$this->updateAccountLimits($organization);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update account limits to organization values
|
||||
*/
|
||||
private function updateAccountLimits(Organization $organization): void
|
||||
{
|
||||
$this->info('Updating account limits and names...');
|
||||
|
||||
$userAccountName = timebank_config('accounts.user.name', 'personal');
|
||||
$orgAccountName = timebank_config('accounts.organization.name', 'organization');
|
||||
$limitMin = timebank_config('accounts.organization.limit_min', 0);
|
||||
$limitMax = timebank_config('accounts.organization.limit_max', 12000);
|
||||
|
||||
// Get all accounts for this organization
|
||||
$accounts = DB::table('accounts')
|
||||
->where('accountable_type', 'App\Models\Organization')
|
||||
->where('accountable_id', $organization->id)
|
||||
->get(['id', 'name']);
|
||||
|
||||
$limitsUpdated = 0;
|
||||
$namesRenamed = 0;
|
||||
$assignedNames = []; // Track names assigned during this migration
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$updateData = [
|
||||
'limit_min' => $limitMin,
|
||||
'limit_max' => $limitMax
|
||||
];
|
||||
|
||||
// Rename accounts based on their current name
|
||||
if ($account->name === $userAccountName) {
|
||||
// Rename 'personal' to 'organization' (with numbering if needed)
|
||||
$newName = $this->generateUniqueAccountName($organization, $orgAccountName, $assignedNames);
|
||||
$updateData['name'] = $newName;
|
||||
$assignedNames[] = $newName; // Track this assigned name
|
||||
$namesRenamed++;
|
||||
$this->line(" Renaming account ID {$account->id}: '{$account->name}' → '{$newName}'");
|
||||
} elseif ($account->name === 'gift') {
|
||||
// Rename 'gift' to 'donation' (with numbering if needed)
|
||||
$newName = $this->generateUniqueAccountName($organization, 'donation', $assignedNames);
|
||||
$updateData['name'] = $newName;
|
||||
$assignedNames[] = $newName; // Track this assigned name
|
||||
$namesRenamed++;
|
||||
$this->line(" Renaming account ID {$account->id}: '{$account->name}' → '{$newName}'");
|
||||
}
|
||||
|
||||
// Update the account
|
||||
DB::table('accounts')
|
||||
->where('id', $account->id)
|
||||
->update($updateData);
|
||||
|
||||
$limitsUpdated++;
|
||||
}
|
||||
|
||||
if ($limitsUpdated > 0) {
|
||||
$this->line(" Updated limits for {$limitsUpdated} account(s): min={$limitMin}, max={$limitMax}");
|
||||
}
|
||||
|
||||
if ($namesRenamed > 0) {
|
||||
$this->line(" Renamed {$namesRenamed} account(s) (personal→organization, gift→donation)");
|
||||
}
|
||||
|
||||
if ($limitsUpdated === 0) {
|
||||
$this->line(" No accounts found to update");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique account name for the organization
|
||||
*/
|
||||
private function generateUniqueAccountName(Organization $organization, string $baseName, array $accountsBeingRenamed = []): string
|
||||
{
|
||||
// Get all existing account names for this organization
|
||||
$existingNames = DB::table('accounts')
|
||||
->where('accountable_type', 'App\Models\Organization')
|
||||
->where('accountable_id', $organization->id)
|
||||
->pluck('name')
|
||||
->toArray();
|
||||
|
||||
// Also consider names that are being assigned in this migration batch
|
||||
$existingNames = array_merge($existingNames, $accountsBeingRenamed);
|
||||
|
||||
// If base name doesn't exist, use it
|
||||
if (!in_array($baseName, $existingNames)) {
|
||||
return $baseName;
|
||||
}
|
||||
|
||||
// Try numbered versions until we find one that doesn't exist
|
||||
$counter = 2;
|
||||
while (true) {
|
||||
$candidateName = $baseName . ' ' . $counter;
|
||||
if (!in_array($candidateName, $existingNames)) {
|
||||
return $candidateName;
|
||||
}
|
||||
$counter++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update love package relationships
|
||||
*/
|
||||
private function updateLoveRelationships(User $user, Organization $organization): void
|
||||
{
|
||||
$this->info('Updating love package relationships...');
|
||||
|
||||
// Update love_reactants type from User to Organization
|
||||
if ($user->loveReactant) {
|
||||
$updated = DB::table('love_reactants')
|
||||
->where('id', $user->loveReactant->id)
|
||||
->update(['type' => 'App\Models\Organization']);
|
||||
|
||||
if ($updated > 0) {
|
||||
$this->line(" love_reactants: Updated type for reactant ID {$user->loveReactant->id}");
|
||||
}
|
||||
}
|
||||
|
||||
// Update love_reacters type from User to Organization
|
||||
if ($user->loveReacter) {
|
||||
$updated = DB::table('love_reacters')
|
||||
->where('id', $user->loveReacter->id)
|
||||
->update(['type' => 'App\Models\Organization']);
|
||||
|
||||
if ($updated > 0) {
|
||||
$this->line(" love_reacters: Updated type for reacter ID {$user->loveReacter->id}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!$user->loveReactant && !$user->loveReacter) {
|
||||
$this->line(" No love relationships to update");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pivot table migrations
|
||||
*/
|
||||
private function updatePivotTables(User $user, Organization $organization): void
|
||||
{
|
||||
$this->info('Updating pivot tables...');
|
||||
|
||||
// Handle bank_user -> bank_organization migration
|
||||
$bankRelationships = DB::table('bank_user')->where('user_id', $user->id)->get();
|
||||
foreach ($bankRelationships as $relationship) {
|
||||
// Create new bank_organization relationship
|
||||
DB::table('bank_organization')->insertOrIgnore([
|
||||
'bank_id' => $relationship->bank_id,
|
||||
'organization_id' => $organization->id,
|
||||
'created_at' => $relationship->created_at ?? now(),
|
||||
'updated_at' => $relationship->updated_at ?? now()
|
||||
]);
|
||||
}
|
||||
|
||||
if ($bankRelationships->count() > 0) {
|
||||
$this->line(" bank_organization: Migrated {$bankRelationships->count()} relationships");
|
||||
// Delete old bank_user relationships
|
||||
DB::table('bank_user')->where('user_id', $user->id)->delete();
|
||||
}
|
||||
|
||||
// Make the User a manager of the new Organization
|
||||
DB::table('organization_user')->insertOrIgnore([
|
||||
'organization_id' => $organization->id,
|
||||
'user_id' => $user->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
$this->line(" organization_user: Added User as manager");
|
||||
|
||||
// Handle other pivot tables
|
||||
$pivotTables = $this->getPivotTables();
|
||||
foreach ($pivotTables as $table => $column) {
|
||||
if ($table === 'bank_user') {
|
||||
continue;
|
||||
} // Already handled above
|
||||
|
||||
$count = DB::table($table)->where($column, $user->id)->count();
|
||||
if ($count > 0) {
|
||||
$this->line(" {$table}: {$count} records need manual review");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update direct references
|
||||
*/
|
||||
private function updateDirectReferences(User $user, Organization $organization): void
|
||||
{
|
||||
$this->info('Updating direct references...');
|
||||
|
||||
// Handle Spatie Permission tables
|
||||
DB::table('model_has_roles')
|
||||
->where('model_type', 'App\Models\User')
|
||||
->where('model_id', $user->id)
|
||||
->update([
|
||||
'model_type' => 'App\Models\Organization',
|
||||
'model_id' => $organization->id
|
||||
]);
|
||||
|
||||
DB::table('model_has_permissions')
|
||||
->where('model_type', 'App\Models\User')
|
||||
->where('model_id', $user->id)
|
||||
->update([
|
||||
'model_type' => 'App\Models\Organization',
|
||||
'model_id' => $organization->id
|
||||
]);
|
||||
|
||||
$this->line(" Updated permission system references");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle special cases like Elasticsearch, caches, etc.
|
||||
*/
|
||||
private function handleSpecialCases(User $user, Organization $organization): void
|
||||
{
|
||||
$this->info('Handling special cases...');
|
||||
|
||||
// Re-index in Elasticsearch
|
||||
try {
|
||||
$organization->searchable();
|
||||
$this->line(" Updated Elasticsearch index");
|
||||
} catch (\Exception $e) {
|
||||
$this->line(" Elasticsearch update failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Clear caches
|
||||
if (function_exists('cache')) {
|
||||
cache()->forget("user.{$user->id}");
|
||||
$this->line(" Cleared user cache");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get polymorphic table mappings
|
||||
*/
|
||||
private function getPolymorphicTables(): array
|
||||
{
|
||||
$tables = [
|
||||
'accounts' => ['type' => 'accountable_type', 'id' => 'accountable_id'],
|
||||
'locations' => ['type' => 'locatable_type', 'id' => 'locatable_id'],
|
||||
'posts' => ['type' => 'postable_type', 'id' => 'postable_id'],
|
||||
'categories' => ['type' => 'categoryable_type', 'id' => 'categoryable_id'],
|
||||
'activity_log' => ['type' => 'subject_type', 'id' => 'subject_id'],
|
||||
];
|
||||
|
||||
// Check for optional tables that might exist
|
||||
if ($this->tableExists('languagables')) {
|
||||
$tables['languagables'] = ['type' => 'languagable_type', 'id' => 'languagable_id'];
|
||||
}
|
||||
|
||||
if ($this->tableExists('sociables')) {
|
||||
$tables['sociables'] = ['type' => 'sociable_type', 'id' => 'sociable_id'];
|
||||
}
|
||||
|
||||
if ($this->tableExists('bank_clients')) {
|
||||
$tables['bank_clients'] = ['type' => 'client_type', 'id' => 'client_id'];
|
||||
}
|
||||
|
||||
// Love package tables are handled separately in updateLoveRelationships()
|
||||
// as they use a different pattern (type column instead of polymorphic columns)
|
||||
|
||||
return $tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pivot table mappings
|
||||
*/
|
||||
private function getPivotTables(): array
|
||||
{
|
||||
return [
|
||||
'bank_user' => 'user_id',
|
||||
'admin_user' => 'user_id',
|
||||
'organization_user' => 'user_id',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a table exists
|
||||
*/
|
||||
private function tableExists(string $tableName): bool
|
||||
{
|
||||
try {
|
||||
DB::table($tableName)->limit(1)->count();
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if user can be safely migrated
|
||||
*/
|
||||
private function validateUserForMigration(User $user): array
|
||||
{
|
||||
$blockingErrors = [];
|
||||
$warnings = [];
|
||||
|
||||
// BLOCKING ERRORS - Migration cannot proceed
|
||||
|
||||
// Check if an organization with the same name already exists
|
||||
if (Organization::where('name', $user->name)->exists()) {
|
||||
$blockingErrors[] = "An Organization with name '{$user->name}' already exists";
|
||||
}
|
||||
|
||||
// Check if an organization with the same email already exists
|
||||
if (Organization::where('email', $user->email)->exists()) {
|
||||
$blockingErrors[] = "An Organization with email '{$user->email}' already exists";
|
||||
}
|
||||
|
||||
// Check if user is currently online (has recent presence)
|
||||
try {
|
||||
if (method_exists($user, 'isOnline') && $user->isOnline()) {
|
||||
$blockingErrors[] = "User is currently online - wait for them to go offline before migrating";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Skip online check if presence system is not available
|
||||
}
|
||||
|
||||
// WARNINGS - Migration can proceed with confirmation
|
||||
|
||||
// Check if user is a super admin or critical system user
|
||||
if ($user->hasRole('Super Admin') || $user->hasRole('super-admin')) {
|
||||
$warnings[] = "User has Super Admin role - all admin privileges will be removed during migration";
|
||||
}
|
||||
|
||||
// Check if user has critical system permissions
|
||||
$criticalPermissions = ['manage system', 'manage users', 'super-admin'];
|
||||
foreach ($criticalPermissions as $permission) {
|
||||
if ($user->can($permission)) {
|
||||
$warnings[] = "User has critical permission '{$permission}' - all admin permissions will be removed during migration";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has active two-factor authentication
|
||||
if (!empty($user->two_factor_secret)) {
|
||||
$warnings[] = "User has two-factor authentication enabled - this will be lost during migration";
|
||||
}
|
||||
|
||||
// ORGANIZATION CONFLICTS - Separate from general warnings
|
||||
$organizationConflicts = [];
|
||||
|
||||
// Check if user is already managing banks
|
||||
if ($user->banksManaged()->count() > 0) {
|
||||
$organizationConflicts[] = "User is managing " . $user->banksManaged()->count() . " bank(s). After the migration this will not be possible any more.";
|
||||
}
|
||||
|
||||
// Check if user is already managing organizations
|
||||
if ($user->organizations()->count() > 0) {
|
||||
$organizationConflicts[] = "User is managing " . $user->organizations()->count() . " organization(s). After the migration this will not be possible any more.";
|
||||
}
|
||||
|
||||
return [
|
||||
'blocking_errors' => $blockingErrors,
|
||||
'warnings' => $warnings,
|
||||
'organization_conflicts' => $organizationConflicts
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove admin relationships and roles before migration
|
||||
*/
|
||||
private function removeAdminRelationshipsAndRoles(User $user): void
|
||||
{
|
||||
$this->info('Removing admin relationships and roles...');
|
||||
|
||||
// Remove admin roles using Spatie method
|
||||
$adminRoles = ['admin', 'super-admin', 'Admin', 'Super Admin'];
|
||||
$rolesRemoved = [];
|
||||
|
||||
foreach ($adminRoles as $roleName) {
|
||||
if ($user->hasRole($roleName)) {
|
||||
$user->removeRole($roleName);
|
||||
$rolesRemoved[] = $roleName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($rolesRemoved)) {
|
||||
$this->line(' Removed roles: ' . implode(', ', $rolesRemoved));
|
||||
}
|
||||
|
||||
// Remove admin relationships (many-to-many)
|
||||
$adminCount = $user->admins()->count();
|
||||
if ($adminCount > 0) {
|
||||
$user->admins()->detach();
|
||||
$this->line(" Removed {$adminCount} admin relationships");
|
||||
}
|
||||
|
||||
// Remove bank management relationships
|
||||
$bankCount = $user->banksManaged()->count();
|
||||
if ($bankCount > 0) {
|
||||
$bankNames = $user->banksManaged()->pluck('name')->toArray();
|
||||
$user->banksManaged()->detach(); // Un-associate all managed banks
|
||||
$this->line(" Removed management of {$bankCount} bank(s): " . implode(', ', $bankNames));
|
||||
}
|
||||
|
||||
// Remove organization management relationships
|
||||
$organizationCount = $user->organizations()->count();
|
||||
if ($organizationCount > 0) {
|
||||
$orgNames = $user->organizations()->pluck('name')->toArray();
|
||||
$user->organizations()->detach(); // Un-associate all managed organizations
|
||||
$this->line(" Removed management of {$organizationCount} organization(s): " . implode(', ', $orgNames));
|
||||
}
|
||||
|
||||
if (empty($rolesRemoved) && $adminCount === 0 && $bankCount === 0 && $organizationCount === 0) {
|
||||
$this->line(' No admin relationships or roles to remove');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete User and cleanup all remaining relationships
|
||||
*/
|
||||
private function deleteUserAndRelationships(User $user, Organization $organization): void
|
||||
{
|
||||
$this->info('Deleting User and cleaning up relationships...');
|
||||
|
||||
// Step 1: Verify critical data was migrated
|
||||
$this->verifyMigrationCompleteness($user, $organization);
|
||||
|
||||
// Step 2: Clean up pivot table relationships that weren't migrated
|
||||
$this->cleanupPivotRelationships($user);
|
||||
|
||||
// Step 3: Clean up remaining foreign key references
|
||||
$this->cleanupForeignKeyReferences($user);
|
||||
|
||||
// Step 4: Delete the User model
|
||||
$userId = $user->id;
|
||||
$userName = $user->name;
|
||||
|
||||
$user->delete();
|
||||
|
||||
$this->line(" Deleted User '{$userName}' (ID: {$userId})");
|
||||
|
||||
// Step 5: Verify complete deletion
|
||||
$this->verifyUserDeletion($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that critical data was successfully migrated
|
||||
*/
|
||||
private function verifyMigrationCompleteness(User $user, Organization $organization): void
|
||||
{
|
||||
// Check that accounts were transferred
|
||||
$userAccounts = $user->accounts()->count();
|
||||
$orgAccounts = $organization->accounts()->count();
|
||||
|
||||
if ($userAccounts > 0) {
|
||||
throw new \Exception("User still has {$userAccounts} accounts - migration incomplete");
|
||||
}
|
||||
|
||||
if ($orgAccounts === 0) {
|
||||
$this->line(" Organization has no accounts - this may be expected");
|
||||
}
|
||||
|
||||
$this->line(" Migration verification passed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up pivot table relationships
|
||||
*/
|
||||
private function cleanupPivotRelationships(User $user): void
|
||||
{
|
||||
$cleanupTables = [
|
||||
'organization_user' => 'user_id',
|
||||
'bank_user' => 'user_id'
|
||||
// admin_user is already handled in removeAdminRelationshipsAndRoles()
|
||||
];
|
||||
|
||||
foreach ($cleanupTables as $table => $column) {
|
||||
if ($this->tableExists($table)) {
|
||||
$count = DB::table($table)->where($column, $user->id)->count();
|
||||
if ($count > 0) {
|
||||
DB::table($table)->where($column, $user->id)->delete();
|
||||
$this->line(" Cleaned up {$table}: {$count} records deleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up Spatie permission pivot tables
|
||||
$permissionTables = [
|
||||
'model_has_roles' => ['model_type' => 'App\Models\User', 'model_id' => $user->id],
|
||||
'model_has_permissions' => ['model_type' => 'App\Models\User', 'model_id' => $user->id]
|
||||
];
|
||||
|
||||
foreach ($permissionTables as $table => $conditions) {
|
||||
if ($this->tableExists($table)) {
|
||||
$count = DB::table($table)->where($conditions)->count();
|
||||
if ($count > 0) {
|
||||
DB::table($table)->where($conditions)->delete();
|
||||
$this->line(" Cleaned up {$table}: {$count} records deleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up foreign key references
|
||||
*/
|
||||
private function cleanupForeignKeyReferences(User $user): void
|
||||
{
|
||||
// Clean up activity logs where User is the causer (not subject - those are audit trail)
|
||||
if ($this->tableExists('activity_log')) {
|
||||
$count = DB::table('activity_log')
|
||||
->where('causer_type', 'App\Models\User')
|
||||
->where('causer_id', $user->id)
|
||||
->count();
|
||||
|
||||
if ($count > 0) {
|
||||
// Set causer to null instead of deleting logs for audit trail
|
||||
DB::table('activity_log')
|
||||
->where('causer_type', 'App\Models\User')
|
||||
->where('causer_id', $user->id)
|
||||
->update([
|
||||
'causer_type' => null,
|
||||
'causer_id' => null
|
||||
]);
|
||||
$this->line(" Cleaned up activity_log causers: {$count} records updated");
|
||||
}
|
||||
}
|
||||
|
||||
// Love package cleanup is handled by updateLoveRelationships() method
|
||||
// No additional cleanup needed as we're updating types, not deleting records
|
||||
|
||||
// Clean up any remaining chat/messaging relationships
|
||||
$chatTables = ['chat_participants', 'chat_messages'];
|
||||
foreach ($chatTables as $table) {
|
||||
if ($this->tableExists($table)) {
|
||||
$userColumn = $table === 'chat_participants' ? 'user_id' : 'sender_id';
|
||||
$count = DB::table($table)->where($userColumn, $user->id)->count();
|
||||
|
||||
if ($count > 0) {
|
||||
if ($table === 'chat_messages') {
|
||||
// For messages, mark as deleted rather than removing for chat history
|
||||
DB::table($table)
|
||||
->where($userColumn, $user->id)
|
||||
->update(['sender_id' => null, 'deleted_at' => now()]);
|
||||
$this->line(" Cleaned up {$table}: {$count} records marked as deleted");
|
||||
} else {
|
||||
DB::table($table)->where($userColumn, $user->id)->delete();
|
||||
$this->line(" Cleaned up {$table}: {$count} records deleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify User was completely deleted
|
||||
*/
|
||||
private function verifyUserDeletion(int $userId): void
|
||||
{
|
||||
// Check that User record is gone
|
||||
if (User::find($userId)) {
|
||||
throw new \Exception("User deletion failed - User {$userId} still exists");
|
||||
}
|
||||
|
||||
// Check for any remaining references in key tables
|
||||
$checkTables = [
|
||||
'organization_user' => 'user_id',
|
||||
'bank_user' => 'user_id',
|
||||
'admin_user' => 'user_id'
|
||||
];
|
||||
|
||||
foreach ($checkTables as $table => $column) {
|
||||
if ($this->tableExists($table)) {
|
||||
$remaining = DB::table($table)->where($column, $userId)->count();
|
||||
if ($remaining > 0) {
|
||||
$this->line(" Warning: {$remaining} records remain in {$table}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(" User deletion verification completed");
|
||||
}
|
||||
}
|
||||
105
app/Console/Commands/PermanentlyDeleteExpiredProfiles.php
Normal file
105
app/Console/Commands/PermanentlyDeleteExpiredProfiles.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Jetstream\DeleteUser;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PermanentlyDeleteExpiredProfiles extends Command
|
||||
{
|
||||
protected $signature = 'profiles:permanently-delete-expired';
|
||||
|
||||
protected $description = 'Permanently delete (anonymize) profiles that have exceeded the grace period after deletion';
|
||||
|
||||
protected $logFile;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->logFile = storage_path('logs/permanent-deletions.log');
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Processing profiles pending permanent deletion...');
|
||||
$this->logMessage('=== Starting permanent deletion processing ===');
|
||||
|
||||
// Get grace period from config (in days)
|
||||
$gracePeriodDays = timebank_config('delete_profile.grace_period_days', 30);
|
||||
$gracePeriodSeconds = $gracePeriodDays * 86400;
|
||||
|
||||
$totalPermanentDeletions = 0;
|
||||
|
||||
// Process each profile type
|
||||
$profileTypes = [
|
||||
'User' => User::class,
|
||||
'Organization' => Organization::class,
|
||||
'Bank' => Bank::class,
|
||||
'Admin' => Admin::class,
|
||||
];
|
||||
|
||||
foreach ($profileTypes as $typeName => $modelClass) {
|
||||
// Find profiles that:
|
||||
// 1. Have deleted_at set (marked for deletion)
|
||||
// 2. Grace period has expired
|
||||
// 3. Have not been anonymized yet (check by email not being removed-*@remove.ed)
|
||||
$profiles = $modelClass::whereNotNull('deleted_at')
|
||||
->where('deleted_at', '<=', now()->subSeconds($gracePeriodSeconds))
|
||||
->where('email', 'not like', 'removed-%@remove.ed')
|
||||
->get();
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$result = $this->permanentlyDeleteProfile($profile, $typeName);
|
||||
if ($result === 'deleted') {
|
||||
$totalPermanentDeletions++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Processing complete: {$totalPermanentDeletions} profiles permanently deleted");
|
||||
$this->logMessage("=== Completed: {$totalPermanentDeletions} permanent deletions ===\n");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function permanentlyDeleteProfile($profile, $profileType)
|
||||
{
|
||||
$profileName = $profile->name;
|
||||
$profileId = $profile->id;
|
||||
$deletedAt = $profile->deleted_at;
|
||||
|
||||
try {
|
||||
// Use the DeleteUser action's permanentlyDelete method
|
||||
$deleteUser = new DeleteUser();
|
||||
$result = $deleteUser->permanentlyDelete($profile);
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
$this->logMessage("[{$profileType}] PERMANENTLY DELETED {$profileName} (ID: {$profileId}) - Originally deleted at {$deletedAt}");
|
||||
$this->info("[{$profileType}] Permanently deleted: {$profileName}");
|
||||
return 'deleted';
|
||||
} else {
|
||||
$this->logMessage("[{$profileType}] ERROR permanently deleting {$profileName} (ID: {$profileId}): {$result['message']}");
|
||||
$this->error("[{$profileType}] Error: {$profileName} - {$result['message']}");
|
||||
return null;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logMessage("[{$profileType}] ERROR permanently deleting {$profileName} (ID: {$profileId}): {$e->getMessage()}");
|
||||
$this->error("[{$profileType}] Error: {$profileName} - {$e->getMessage()}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function logMessage($message)
|
||||
{
|
||||
$timestamp = now()->format('Y-m-d H:i:s');
|
||||
$logEntry = "[{$timestamp}] {$message}\n";
|
||||
|
||||
file_put_contents($this->logFile, $logEntry, FILE_APPEND);
|
||||
Log::info($message);
|
||||
}
|
||||
}
|
||||
380
app/Console/Commands/ProcessBounceMailings.php
Normal file
380
app/Console/Commands/ProcessBounceMailings.php
Normal file
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MailingBounce;
|
||||
use App\Models\Mailing;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessBounceMailings extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'mailings:process-bounces
|
||||
{--mailbox= : Email address to check for bounces (e.g. bounces@yourdomain.com)}
|
||||
{--host= : IMAP/POP3 server hostname}
|
||||
{--port= : Server port (default: 993 for IMAP SSL, 995 for POP3 SSL)}
|
||||
{--protocol= : Protocol to use: imap or pop3 (default: imap)}
|
||||
{--username= : Login username}
|
||||
{--password= : Login password}
|
||||
{--ssl : Use SSL connection}
|
||||
{--delete : Delete processed bounce emails}
|
||||
{--dry-run : Show what would be processed without actually processing}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Process bounce emails from a dedicated bounce mailbox for mailings';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
// Get configuration from command options or config file
|
||||
$config = $this->getMailboxConfig();
|
||||
|
||||
if (!$config) {
|
||||
$this->error('Mailbox configuration is required. Use options or configure in config/mail.php');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
try {
|
||||
$connection = $this->connectToMailbox($config);
|
||||
$emails = $this->fetchBounceEmails($connection, $config);
|
||||
|
||||
if (empty($emails)) {
|
||||
$this->info('No bounce emails found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found " . count($emails) . " bounce emails to process:");
|
||||
|
||||
$processed = 0;
|
||||
foreach ($emails as $email) {
|
||||
$bounceInfo = $this->parseBounceEmail($email);
|
||||
|
||||
if ($bounceInfo) {
|
||||
$this->line("Processing bounce for: {$bounceInfo['email']} ({$bounceInfo['type']})");
|
||||
|
||||
if (!$dryRun) {
|
||||
MailingBounce::recordBounce(
|
||||
$bounceInfo['email'],
|
||||
$bounceInfo['type'],
|
||||
$bounceInfo['reason'],
|
||||
$bounceInfo['mailing_id'] ?? null
|
||||
);
|
||||
|
||||
if ($this->option('delete')) {
|
||||
$this->deleteEmail($connection, $email['id'], $config);
|
||||
}
|
||||
}
|
||||
$processed++;
|
||||
} else {
|
||||
$this->warn("Could not parse bounce email ID: {$email['id']}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Processed {$processed} bounce emails" . ($dryRun ? ' (dry run)' : ''));
|
||||
$this->closeConnection($connection, $config);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Error processing bounce emails: " . $e->getMessage());
|
||||
Log::error("Bounce processing error: " . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mailbox configuration
|
||||
*/
|
||||
protected function getMailboxConfig(): ?array
|
||||
{
|
||||
// Try command options first
|
||||
if ($this->option('mailbox')) {
|
||||
return [
|
||||
'mailbox' => $this->option('mailbox'),
|
||||
'host' => $this->option('host'),
|
||||
'port' => $this->option('port') ?: ($this->option('protocol') === 'pop3' ? 995 : 993),
|
||||
'protocol' => $this->option('protocol') ?: 'imap',
|
||||
'username' => $this->option('username'),
|
||||
'password' => $this->option('password'),
|
||||
'ssl' => $this->option('ssl')
|
||||
];
|
||||
}
|
||||
|
||||
// Try config file
|
||||
$config = config('mail.bounce_processing');
|
||||
if ($config && isset($config['mailbox'])) {
|
||||
return $config;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to mailbox
|
||||
*/
|
||||
protected function connectToMailbox(array $config)
|
||||
{
|
||||
$protocol = strtolower($config['protocol']);
|
||||
|
||||
if ($protocol === 'imap') {
|
||||
return $this->connectIMAP($config);
|
||||
} elseif ($protocol === 'pop3') {
|
||||
return $this->connectPOP3($config);
|
||||
}
|
||||
|
||||
throw new \Exception("Unsupported protocol: {$protocol}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect via IMAP
|
||||
*/
|
||||
protected function connectIMAP(array $config)
|
||||
{
|
||||
$host = $config['host'];
|
||||
$port = $config['port'];
|
||||
$ssl = $config['ssl'] ? '/ssl' : '';
|
||||
|
||||
$mailbox = "{{$host}:{$port}/imap{$ssl}}INBOX";
|
||||
|
||||
$connection = imap_open($mailbox, $config['username'], $config['password']);
|
||||
|
||||
if (!$connection) {
|
||||
throw new \Exception("Failed to connect to IMAP server: " . imap_last_error());
|
||||
}
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect via POP3 (basic implementation)
|
||||
*/
|
||||
protected function connectPOP3(array $config)
|
||||
{
|
||||
throw new \Exception("POP3 support not implemented yet. Use IMAP instead.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch bounce emails
|
||||
*/
|
||||
protected function fetchBounceEmails($connection, array $config): array
|
||||
{
|
||||
$emails = [];
|
||||
$numMessages = imap_num_msg($connection);
|
||||
|
||||
for ($i = 1; $i <= $numMessages; $i++) {
|
||||
$header = imap_headerinfo($connection, $i);
|
||||
$subject = $header->subject ?? '';
|
||||
|
||||
// Check if this looks like a bounce email
|
||||
if ($this->isBounceEmail($subject, $header)) {
|
||||
$body = imap_body($connection, $i);
|
||||
$emails[] = [
|
||||
'id' => $i,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'header' => $header
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $emails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email is a bounce
|
||||
*/
|
||||
protected function isBounceEmail(string $subject, $header): bool
|
||||
{
|
||||
$bounceIndicators = [
|
||||
'delivery status notification',
|
||||
'returned mail',
|
||||
'undelivered mail',
|
||||
'mail delivery failed',
|
||||
'bounce',
|
||||
'mailer-daemon',
|
||||
'postmaster',
|
||||
'delivery failure',
|
||||
'mail system error'
|
||||
];
|
||||
|
||||
$subjectLower = strtolower($subject);
|
||||
foreach ($bounceIndicators as $indicator) {
|
||||
if (strpos($subjectLower, $indicator) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check sender
|
||||
$from = $header->from[0]->mailbox ?? '';
|
||||
$bounceFroms = ['mailer-daemon', 'postmaster', 'mail-daemon'];
|
||||
|
||||
foreach ($bounceFroms as $bounceSender) {
|
||||
if (strpos(strtolower($from), $bounceSender) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse bounce email to extract information
|
||||
*/
|
||||
protected function parseBounceEmail(array $email): ?array
|
||||
{
|
||||
$body = $email['body'];
|
||||
$subject = $email['subject'];
|
||||
|
||||
// Extract original recipient email
|
||||
$recipientEmail = $this->extractRecipientEmail($body);
|
||||
if (!$recipientEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine bounce type and reason
|
||||
$bounceType = $this->determineBounceType($body, $subject);
|
||||
$reason = $this->extractBounceReason($body, $subject);
|
||||
|
||||
// Try to extract mailing ID if present
|
||||
$mailingId = $this->extractMailingIdFromBounce($body, $subject);
|
||||
|
||||
return [
|
||||
'email' => $recipientEmail,
|
||||
'type' => $bounceType,
|
||||
'reason' => $reason,
|
||||
'mailing_id' => $mailingId
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract recipient email from bounce message
|
||||
*/
|
||||
protected function extractRecipientEmail(string $body): ?string
|
||||
{
|
||||
// Common patterns for recipient extraction
|
||||
$patterns = [
|
||||
'/(?:to|for|recipient):\s*<?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>?/i',
|
||||
'/final-recipient:\s*rfc822;\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i',
|
||||
'/original-recipient:\s*rfc822;\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i',
|
||||
'/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i' // Generic email pattern
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $body, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine bounce type from message content
|
||||
*/
|
||||
protected function determineBounceType(string $body, string $subject): string
|
||||
{
|
||||
$bodyLower = strtolower($body . ' ' . $subject);
|
||||
|
||||
// Hard bounce patterns
|
||||
$hardBouncePatterns = [
|
||||
'user unknown',
|
||||
'no such user',
|
||||
'invalid recipient',
|
||||
'recipient address rejected',
|
||||
'mailbox unavailable',
|
||||
'does not exist',
|
||||
'5.1.1', '5.1.2', '5.1.3', // SMTP codes
|
||||
'550', '551', '553', '554'
|
||||
];
|
||||
|
||||
foreach ($hardBouncePatterns as $pattern) {
|
||||
if (strpos($bodyLower, $pattern) !== false) {
|
||||
return 'hard';
|
||||
}
|
||||
}
|
||||
|
||||
// Soft bounce patterns
|
||||
$softBouncePatterns = [
|
||||
'mailbox full',
|
||||
'quota exceeded',
|
||||
'temporarily rejected',
|
||||
'try again later',
|
||||
'temporarily unavailable',
|
||||
'4.2.2', '4.3.1', '4.3.2', // SMTP codes
|
||||
'421', '450', '451', '452'
|
||||
];
|
||||
|
||||
foreach ($softBouncePatterns as $pattern) {
|
||||
if (strpos($bodyLower, $pattern) !== false) {
|
||||
return 'soft';
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bounce reason
|
||||
*/
|
||||
protected function extractBounceReason(string $body, string $subject): string
|
||||
{
|
||||
// Look for diagnostic code or action field
|
||||
if (preg_match('/diagnostic-code:\s*(.+)/i', $body, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
|
||||
if (preg_match('/action:\s*(.+)/i', $body, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
|
||||
// Fallback to subject
|
||||
return substr($subject, 0, 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract mailing ID from bounce if present
|
||||
*/
|
||||
protected function extractMailingIdFromBounce(string $body, string $subject): ?int
|
||||
{
|
||||
// Look for custom headers or message IDs that contain mailing info
|
||||
if (preg_match('/mailing[_-]?id[:\s]*(\d+)/i', $body, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
if (preg_match('/x-mailing-id[:\s]*(\d+)/i', $body, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete processed email
|
||||
*/
|
||||
protected function deleteEmail($connection, int $messageId, array $config): void
|
||||
{
|
||||
if ($config['protocol'] === 'imap') {
|
||||
imap_delete($connection, $messageId);
|
||||
imap_expunge($connection);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close connection
|
||||
*/
|
||||
protected function closeConnection($connection, array $config): void
|
||||
{
|
||||
if ($config['protocol'] === 'imap') {
|
||||
imap_close($connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
app/Console/Commands/ProcessCallExpiry.php
Normal file
76
app/Console/Commands/ProcessCallExpiry.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\CallExpiredMail;
|
||||
use App\Mail\CallExpiringMail;
|
||||
use App\Models\Call;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class ProcessCallExpiry extends Command
|
||||
{
|
||||
protected $signature = 'calls:process-expiry';
|
||||
protected $description = 'Send expiry warning and expired notification emails for calls';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$warningDays = (int) timebank_config('calls.expiry_warning_days', 7);
|
||||
|
||||
// Calls expiring in exactly $warningDays days
|
||||
$warnDate = now()->addDays($warningDays)->toDateString();
|
||||
$expiringSoon = Call::with(['callable', 'tag'])
|
||||
->whereNotNull('till')
|
||||
->whereDate('till', $warnDate)
|
||||
->where('is_suppressed', false)
|
||||
->where('is_paused', false)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
$warnCount = 0;
|
||||
foreach ($expiringSoon as $call) {
|
||||
$callable = $call->callable;
|
||||
if (!$callable || !$callable->email) {
|
||||
continue;
|
||||
}
|
||||
$settings = $callable->message_settings()->first();
|
||||
if ($settings && !($settings->call_expiry ?? true)) {
|
||||
continue;
|
||||
}
|
||||
Mail::to($callable->email)->queue(
|
||||
new CallExpiringMail($call, $callable, class_basename($callable), $warningDays)
|
||||
);
|
||||
$warnCount++;
|
||||
}
|
||||
|
||||
// Calls that expired yesterday
|
||||
$yesterday = now()->subDay()->toDateString();
|
||||
$expired = Call::with(['callable', 'tag'])
|
||||
->whereNotNull('till')
|
||||
->whereDate('till', $yesterday)
|
||||
->where('is_suppressed', false)
|
||||
->where('is_paused', false)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
$expiredCount = 0;
|
||||
foreach ($expired as $call) {
|
||||
$callable = $call->callable;
|
||||
if (!$callable || !$callable->email) {
|
||||
continue;
|
||||
}
|
||||
$settings = $callable->message_settings()->first();
|
||||
if ($settings && !($settings->call_expiry ?? true)) {
|
||||
continue;
|
||||
}
|
||||
Mail::to($callable->email)->queue(
|
||||
new CallExpiredMail($call, $callable, class_basename($callable))
|
||||
);
|
||||
$expiredCount++;
|
||||
}
|
||||
|
||||
$this->info("Call expiry processed: {$warnCount} expiry warnings queued, {$expiredCount} expiry notifications queued.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
311
app/Console/Commands/ProcessInactiveProfiles.php
Normal file
311
app/Console/Commands/ProcessInactiveProfiles.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use App\Mail\InactiveProfileWarning1Mail;
|
||||
use App\Mail\InactiveProfileWarning2Mail;
|
||||
use App\Mail\InactiveProfileWarningFinalMail;
|
||||
use App\Mail\UserDeletedMail;
|
||||
use App\Actions\Jetstream\DeleteUser;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ProcessInactiveProfiles extends Command
|
||||
{
|
||||
protected $signature = 'profiles:process-inactive';
|
||||
|
||||
protected $description = 'Process inactive profiles - send warnings and delete profiles that exceed inactivity thresholds';
|
||||
|
||||
protected $thresholds = [];
|
||||
protected $logFile;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
// Convert configured days to seconds for precise comparison
|
||||
$this->thresholds = [
|
||||
'warning_1' => timebank_config('delete_profile.days_after_inactive.warning_1') * 86400,
|
||||
'warning_2' => timebank_config('delete_profile.days_after_inactive.warning_2') * 86400,
|
||||
'warning_final' => timebank_config('delete_profile.days_after_inactive.warning_final') * 86400,
|
||||
'run_delete' => timebank_config('delete_profile.days_after_inactive.run_delete') * 86400,
|
||||
];
|
||||
|
||||
$this->logFile = storage_path('logs/inactive-profiles.log');
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Processing inactive profiles...');
|
||||
$this->logMessage('=== Starting inactive profile processing ===');
|
||||
|
||||
$totalWarnings = 0;
|
||||
$totalDeletions = 0;
|
||||
|
||||
// Process Users
|
||||
$users = User::whereNotNull('inactive_at') // Only process profiles marked as inactive
|
||||
->whereNull('deleted_at') // Exclude already deleted profiles
|
||||
->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$result = $this->processProfile($user, 'User');
|
||||
if ($result === 'warning') $totalWarnings++;
|
||||
if ($result === 'deleted') $totalDeletions++;
|
||||
}
|
||||
|
||||
// Process Organizations
|
||||
$organizations = Organization::whereNotNull('inactive_at') // Only process profiles marked as inactive
|
||||
->whereNull('deleted_at') // Exclude already deleted profiles
|
||||
->get();
|
||||
|
||||
foreach ($organizations as $organization) {
|
||||
$result = $this->processProfile($organization, 'Organization');
|
||||
if ($result === 'warning') $totalWarnings++;
|
||||
if ($result === 'deleted') $totalDeletions++;
|
||||
}
|
||||
|
||||
$this->info("Processing complete: {$totalWarnings} warnings sent, {$totalDeletions} profiles deleted");
|
||||
$this->logMessage("=== Completed: {$totalWarnings} warnings, {$totalDeletions} deletions ===\n");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function processProfile($profile, $profileType)
|
||||
{
|
||||
if (!$profile->inactive_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$secondsSinceInactive = now()->diffInSeconds($profile->inactive_at);
|
||||
$secondsRemaining = $this->thresholds['run_delete'] - $secondsSinceInactive;
|
||||
|
||||
// Determine action based on thresholds
|
||||
if ($secondsSinceInactive >= $this->thresholds['run_delete']) {
|
||||
// Delete profile
|
||||
return $this->deleteProfile($profile, $profileType, $secondsSinceInactive);
|
||||
} elseif ($secondsSinceInactive >= $this->thresholds['warning_final'] && $secondsSinceInactive < $this->thresholds['run_delete']) {
|
||||
// Send final warning
|
||||
return $this->sendWarning($profile, $profileType, 'final', $secondsRemaining, $secondsSinceInactive);
|
||||
} elseif ($secondsSinceInactive >= $this->thresholds['warning_2'] && $secondsSinceInactive < $this->thresholds['warning_final']) {
|
||||
// Send warning 2
|
||||
return $this->sendWarning($profile, $profileType, 'warning_2', $secondsRemaining, $secondsSinceInactive);
|
||||
} elseif ($secondsSinceInactive >= $this->thresholds['warning_1'] && $secondsSinceInactive < $this->thresholds['warning_2']) {
|
||||
// Send warning 1
|
||||
return $this->sendWarning($profile, $profileType, 'warning_1', $secondsRemaining, $secondsSinceInactive);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function sendWarning($profile, $profileType, $warningLevel, $secondsRemaining, $secondsSinceInactive)
|
||||
{
|
||||
$accountsData = $this->getAccountsData($profile);
|
||||
$totalBalance = $this->getTotalBalance($profile);
|
||||
$timeRemaining = $this->formatTimeRemaining($secondsRemaining);
|
||||
$daysSinceInactive = round($secondsSinceInactive / 86400, 2);
|
||||
|
||||
$mailClass = match($warningLevel) {
|
||||
'warning_1' => InactiveProfileWarning1Mail::class,
|
||||
'warning_2' => InactiveProfileWarning2Mail::class,
|
||||
'final' => InactiveProfileWarningFinalMail::class,
|
||||
};
|
||||
|
||||
// Get recipients
|
||||
$recipients = $this->getRecipients($profile, $profileType);
|
||||
|
||||
// Send email to all recipients
|
||||
foreach ($recipients as $recipient) {
|
||||
Mail::to($recipient['email'])
|
||||
->queue(new $mailClass(
|
||||
$profile,
|
||||
$profileType,
|
||||
$timeRemaining,
|
||||
$secondsRemaining / 86400, // days remaining
|
||||
$accountsData,
|
||||
$totalBalance,
|
||||
$daysSinceInactive
|
||||
));
|
||||
}
|
||||
|
||||
$this->logMessage("[{$profileType}] {$warningLevel} sent to {$profile->name} (ID: {$profile->id}) - Inactive for {$daysSinceInactive} days, {$timeRemaining} remaining");
|
||||
$this->info("[{$profileType}] {$warningLevel}: {$profile->name} ({$timeRemaining} remaining)");
|
||||
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
protected function deleteProfile($profile, $profileType, $secondsSinceInactive)
|
||||
{
|
||||
$daysSinceInactive = round($secondsSinceInactive / 86400, 2);
|
||||
|
||||
try {
|
||||
// Check for negative balances
|
||||
$accountsData = $this->getAccountsData($profile);
|
||||
foreach ($accountsData as $account) {
|
||||
if ($account['balance'] < 0) {
|
||||
$this->logMessage("[{$profileType}] SKIPPED deletion of {$profile->name} (ID: {$profile->id}) - Has negative balance");
|
||||
$this->warn("[{$profileType}] Skipped: {$profile->name} - negative balance");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Store profile data before deletion (needed for email)
|
||||
$totalBalance = $this->getTotalBalance($profile);
|
||||
$profileEmail = $profile->email;
|
||||
$profileName = $profile->name;
|
||||
$profileFullName = $profile->full_name ?? $profile->name;
|
||||
|
||||
// Get the profile's updated_at timestamp
|
||||
$profileTable = $profile->getTable();
|
||||
$time = DB::table($profileTable)
|
||||
->where('id', $profile->id)
|
||||
->pluck('updated_at')
|
||||
->first();
|
||||
$time = Carbon::parse($time);
|
||||
|
||||
// Execute soft deletion (sets deleted_at, handles balances, but doesn't anonymize)
|
||||
// Balance handling: skip donation option, use config elsif logic
|
||||
$deleteUser = new DeleteUser();
|
||||
$result = $deleteUser->delete($profile, 'delete', null, true); // true = isAutoDeleted
|
||||
|
||||
// Check if soft deletion was successful
|
||||
if ($result['status'] === 'success') {
|
||||
// Get auto-delete and grace period configuration
|
||||
$daysNotLoggedIn = timebank_config('profile_inactive.days_not_logged_in');
|
||||
$daysAfterInactive = timebank_config('delete_profile.days_after_inactive.run_delete');
|
||||
$totalDays = $daysNotLoggedIn + $daysAfterInactive;
|
||||
$gracePeriodDays = timebank_config('delete_profile.grace_period_days', 30);
|
||||
|
||||
// Prepare email data (similar to DeleteUserForm.php)
|
||||
$emailData = [
|
||||
'time' => $time->translatedFormat('j F Y, H:i'),
|
||||
'deletedUser' => (object)[
|
||||
'name' => $profileName,
|
||||
'full_name' => $profileFullName,
|
||||
'lang_preference' => $profile->lang_preference ?? config('app.locale', 'en'),
|
||||
],
|
||||
'mail' => $profileEmail,
|
||||
'balanceHandlingOption' => 'delete', // Auto-delete always uses 'delete' option
|
||||
'totalBalance' => $totalBalance,
|
||||
'donationAccountId' => null,
|
||||
'donationAccountName' => null,
|
||||
'donationOrganizationName' => null,
|
||||
'autoDeleted' => true, // Flag to indicate this was an auto-deletion
|
||||
'daysNotLoggedIn' => $daysNotLoggedIn,
|
||||
'daysAfterInactive' => $daysAfterInactive,
|
||||
'totalDaysToDelete' => $totalDays,
|
||||
'gracePeriodDays' => $gracePeriodDays, // Days to restore profile
|
||||
];
|
||||
|
||||
// Send deletion confirmation email
|
||||
Mail::to($profileEmail)->queue(new UserDeletedMail($emailData));
|
||||
|
||||
$this->logMessage("[{$profileType}] SOFT DELETED {$profileName} (ID: {$profile->id}) - Inactive for {$daysSinceInactive} days - Can be restored within {$gracePeriodDays} days - Email sent to {$profileEmail}");
|
||||
$this->info("[{$profileType}] Soft deleted: {$profileName} (restorable for {$gracePeriodDays} days)");
|
||||
|
||||
return 'deleted';
|
||||
} else {
|
||||
$this->logMessage("[{$profileType}] ERROR deleting {$profileName} (ID: {$profile->id}): {$result['message']}");
|
||||
$this->error("[{$profileType}] Error deleting {$profileName}: {$result['message']}");
|
||||
return null;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logMessage("[{$profileType}] ERROR deleting {$profile->name} (ID: {$profile->id}): {$e->getMessage()}");
|
||||
$this->error("[{$profileType}] Error deleting {$profile->name}: {$e->getMessage()}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getRecipients($profile, $profileType)
|
||||
{
|
||||
$recipients = [];
|
||||
|
||||
if ($profileType === 'User') {
|
||||
$recipients[] = [
|
||||
'email' => $profile->email,
|
||||
'name' => $profile->name,
|
||||
];
|
||||
} elseif ($profileType === 'Organization') {
|
||||
// Add organization email
|
||||
$recipients[] = [
|
||||
'email' => $profile->email,
|
||||
'name' => $profile->name,
|
||||
];
|
||||
|
||||
// Add all manager emails
|
||||
$managers = $profile->managers()->get();
|
||||
foreach ($managers as $manager) {
|
||||
$recipients[] = [
|
||||
'email' => $manager->email,
|
||||
'name' => $manager->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $recipients;
|
||||
}
|
||||
|
||||
protected function getAccountsData($profile)
|
||||
{
|
||||
$accounts = [];
|
||||
$profileAccounts = $profile->accounts()->active()->notRemoved()->get();
|
||||
|
||||
foreach ($profileAccounts as $account) {
|
||||
// Clear cache to get fresh balance
|
||||
\Cache::forget("account_balance_{$account->id}");
|
||||
|
||||
$accounts[] = [
|
||||
'id' => $account->id,
|
||||
'name' => $account->name,
|
||||
'balance' => $account->balance, // in minutes
|
||||
'balanceFormatted' => tbFormat($account->balance),
|
||||
];
|
||||
}
|
||||
|
||||
return $accounts;
|
||||
}
|
||||
|
||||
protected function getTotalBalance($profile)
|
||||
{
|
||||
$total = 0;
|
||||
$accountsData = $this->getAccountsData($profile);
|
||||
|
||||
foreach ($accountsData as $account) {
|
||||
$total += $account['balance'];
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
protected function formatTimeRemaining($seconds)
|
||||
{
|
||||
$days = $seconds / 86400;
|
||||
|
||||
if ($days >= 7) {
|
||||
$weeks = round($days / 7);
|
||||
return trans_choice('weeks_remaining', $weeks, ['count' => $weeks]);
|
||||
} elseif ($days >= 1) {
|
||||
$daysRounded = round($days);
|
||||
return trans_choice('days_remaining', $daysRounded, ['count' => $daysRounded]);
|
||||
} elseif ($seconds >= 3600) {
|
||||
$hours = round($seconds / 3600);
|
||||
return trans_choice('hours_remaining', $hours, ['count' => $hours]);
|
||||
} else {
|
||||
$minutes = max(1, round($seconds / 60));
|
||||
return trans_choice('minutes_remaining', $minutes, ['count' => $minutes]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function logMessage($message)
|
||||
{
|
||||
$timestamp = now()->format('Y-m-d H:i:s');
|
||||
$logEntry = "[{$timestamp}] {$message}\n";
|
||||
|
||||
file_put_contents($this->logFile, $logEntry, FILE_APPEND);
|
||||
Log::info($message);
|
||||
}
|
||||
}
|
||||
81
app/Console/Commands/ProcessScheduledMailings.php
Normal file
81
app/Console/Commands/ProcessScheduledMailings.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Mailing;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessScheduledMailings extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'mailings:process-scheduled
|
||||
{--dry-run : Show what would be sent without actually sending}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Process scheduled mailings that are ready to be sent';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('Looking for scheduled mailings ready to be sent...');
|
||||
|
||||
// Find mailings that are scheduled and due to be sent
|
||||
$scheduledMailings = Mailing::where('status', 'scheduled')
|
||||
->where('scheduled_at', '<=', now())
|
||||
->get();
|
||||
|
||||
if ($scheduledMailings->isEmpty()) {
|
||||
$this->info('No scheduled mailings ready to be sent.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found {$scheduledMailings->count()} scheduled mailing(s) ready to be sent:");
|
||||
|
||||
foreach ($scheduledMailings as $mailing) {
|
||||
$this->line("- Mailing ID {$mailing->id}: '{$mailing->title}' (scheduled for {$mailing->scheduled_at})");
|
||||
|
||||
if (!$dryRun) {
|
||||
try {
|
||||
// Update status to sending
|
||||
$mailing->update(['status' => 'sending']);
|
||||
|
||||
// Dispatch the locale-specific jobs
|
||||
$mailing->dispatchLocaleSpecificJobs();
|
||||
|
||||
$this->info(" ✓ Dispatched jobs for mailing ID {$mailing->id}");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ✗ Failed to dispatch mailing ID {$mailing->id}: {$e->getMessage()}");
|
||||
Log::error("Failed to dispatch scheduled mailing {$mailing->id}", [
|
||||
'mailing_id' => $mailing->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$this->line(" (dry run - would dispatch jobs for mailing ID {$mailing->id})");
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('Dry run completed. Use without --dry-run to actually send the mailings.');
|
||||
} else {
|
||||
$this->info('Scheduled mailings processing completed.');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
50
app/Console/Commands/RegisterPostsAsReactants.php
Normal file
50
app/Console/Commands/RegisterPostsAsReactants.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RegisterPostsAsReactants extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'posts:register-reactants';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Register all existing posts as Love reactants';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Registering posts as Love reactants...');
|
||||
|
||||
$posts = Post::all();
|
||||
$registered = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($posts as $post) {
|
||||
if (!$post->isRegisteredAsLoveReactant()) {
|
||||
$post->registerAsLoveReactant();
|
||||
$registered++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Registered {$registered} posts as reactants.");
|
||||
$this->info("Skipped {$skipped} posts (already registered).");
|
||||
$this->info('Done!');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
219
app/Console/Commands/RestoreDeletedProfile.php
Normal file
219
app/Console/Commands/RestoreDeletedProfile.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Jetstream\RestoreProfile;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RestoreDeletedProfile extends Command
|
||||
{
|
||||
protected $signature = 'profiles:restore
|
||||
{username? : The username of the profile to restore}
|
||||
{--list : List all deleted profiles within grace period}
|
||||
{--type= : Filter by profile type (user, organization, bank, admin)}';
|
||||
|
||||
protected $description = 'Restore a deleted profile within the grace period or list all restorable profiles';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
// If --list option is provided, show all deleted profiles
|
||||
if ($this->option('list')) {
|
||||
return $this->listDeletedProfiles();
|
||||
}
|
||||
|
||||
// Get username argument
|
||||
$username = $this->argument('username');
|
||||
|
||||
// If no username provided, ask for it
|
||||
if (!$username) {
|
||||
$username = $this->ask('Enter the username of the profile to restore');
|
||||
}
|
||||
|
||||
if (!$username) {
|
||||
$this->error('Username is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Search for the deleted profile
|
||||
$profile = $this->findDeletedProfile($username);
|
||||
|
||||
if (!$profile) {
|
||||
$this->error("No deleted profile found with username: {$username}");
|
||||
$this->info('Use --list option to see all restorable profiles.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Display profile information
|
||||
$profileType = class_basename(get_class($profile));
|
||||
$deletedAt = $profile->deleted_at;
|
||||
$gracePeriodDays = timebank_config('delete_profile.grace_period_days', 30);
|
||||
$expiresAt = $deletedAt->copy()->addDays($gracePeriodDays);
|
||||
|
||||
// Calculate remaining time more accurately
|
||||
if (now()->greaterThanOrEqualTo($expiresAt)) {
|
||||
$timeRemaining = 'EXPIRED';
|
||||
} else {
|
||||
$daysRemaining = now()->diffInDays($expiresAt, false);
|
||||
if ($daysRemaining > 0) {
|
||||
$timeRemaining = $daysRemaining . ' day' . ($daysRemaining > 1 ? 's' : '');
|
||||
} else {
|
||||
$hoursRemaining = now()->diffInHours($expiresAt, false);
|
||||
$timeRemaining = $hoursRemaining . ' hour' . ($hoursRemaining > 1 ? 's' : '');
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Profile found:");
|
||||
$this->table(
|
||||
['Field', 'Value'],
|
||||
[
|
||||
['Type', $profileType],
|
||||
['Username', $profile->name],
|
||||
['Full Name', $profile->full_name ?? 'N/A'],
|
||||
['Email', $profile->email],
|
||||
['Deleted At', $deletedAt->format('Y-m-d H:i:s')],
|
||||
['Grace Period Expires', $expiresAt->format('Y-m-d H:i:s')],
|
||||
['Days Remaining', $timeRemaining],
|
||||
]
|
||||
);
|
||||
|
||||
// Confirm restoration
|
||||
if (!$this->confirm('Do you want to restore this profile?')) {
|
||||
$this->info('Restoration cancelled.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Restore the profile
|
||||
$restoreAction = new RestoreProfile();
|
||||
$result = $restoreAction->restore($profile);
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
$this->info("✓ Profile '{$profile->name}' has been successfully restored!");
|
||||
Log::info("Profile restored via artisan command", [
|
||||
'profile_type' => get_class($profile),
|
||||
'profile_id' => $profile->id,
|
||||
'profile_name' => $profile->name,
|
||||
'restored_by' => 'CLI',
|
||||
]);
|
||||
return 0;
|
||||
} else {
|
||||
$this->error("✗ Failed to restore profile: {$result['message']}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all deleted profiles within grace period
|
||||
*/
|
||||
protected function listDeletedProfiles()
|
||||
{
|
||||
$gracePeriodDays = timebank_config('delete_profile.grace_period_days', 30);
|
||||
$gracePeriodExpiry = now()->subDays($gracePeriodDays);
|
||||
|
||||
$profileTypes = [
|
||||
'User' => User::class,
|
||||
'Organization' => Organization::class,
|
||||
'Bank' => Bank::class,
|
||||
'Admin' => Admin::class,
|
||||
];
|
||||
|
||||
// Filter by type if provided
|
||||
$typeFilter = $this->option('type');
|
||||
if ($typeFilter) {
|
||||
$typeFilter = ucfirst(strtolower($typeFilter));
|
||||
if (!isset($profileTypes[$typeFilter])) {
|
||||
$this->error("Invalid profile type. Valid types: user, organization, bank, admin");
|
||||
return 1;
|
||||
}
|
||||
$profileTypes = [$typeFilter => $profileTypes[$typeFilter]];
|
||||
}
|
||||
|
||||
$allDeletedProfiles = [];
|
||||
|
||||
foreach ($profileTypes as $typeName => $modelClass) {
|
||||
// Find profiles that are deleted, within grace period, and not anonymized
|
||||
$profiles = $modelClass::whereNotNull('deleted_at')
|
||||
->where('deleted_at', '>', $gracePeriodExpiry)
|
||||
->where('email', 'not like', 'removed-%@remove.ed')
|
||||
->orderBy('deleted_at', 'desc')
|
||||
->get();
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$expiresAt = $profile->deleted_at->copy()->addDays($gracePeriodDays);
|
||||
|
||||
// Calculate remaining time more accurately
|
||||
if (now()->greaterThanOrEqualTo($expiresAt)) {
|
||||
$timeRemaining = 'EXPIRED';
|
||||
} else {
|
||||
$daysRemaining = now()->diffInDays($expiresAt, false);
|
||||
if ($daysRemaining > 0) {
|
||||
$timeRemaining = $daysRemaining . 'd';
|
||||
} else {
|
||||
$hoursRemaining = now()->diffInHours($expiresAt, false);
|
||||
$timeRemaining = $hoursRemaining . 'h';
|
||||
}
|
||||
}
|
||||
|
||||
$allDeletedProfiles[] = [
|
||||
'Type' => $typeName,
|
||||
'Username' => $profile->name,
|
||||
'Full Name' => $profile->full_name ?? 'N/A',
|
||||
'Email' => $profile->email,
|
||||
'Deleted At' => $profile->deleted_at->format('Y-m-d H:i'),
|
||||
'Expires At' => $expiresAt->format('Y-m-d H:i'),
|
||||
'Time Left' => $timeRemaining,
|
||||
'Comment' => $profile->comment ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($allDeletedProfiles)) {
|
||||
$this->info('No deleted profiles found within the grace period.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Deleted profiles within {$gracePeriodDays}-day grace period:");
|
||||
$this->table(
|
||||
['Type', 'Username', 'Full Name', 'Email', 'Deleted At', 'Expires At', 'Time Left', 'Comment'],
|
||||
$allDeletedProfiles
|
||||
);
|
||||
|
||||
$this->info("\nTo restore a profile, run: php artisan profiles:restore {username}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a deleted profile by username across all profile types
|
||||
*/
|
||||
protected function findDeletedProfile($username)
|
||||
{
|
||||
$gracePeriodDays = timebank_config('delete_profile.grace_period_days', 30);
|
||||
$gracePeriodExpiry = now()->subDays($gracePeriodDays);
|
||||
|
||||
$profileTypes = [
|
||||
User::class,
|
||||
Organization::class,
|
||||
Bank::class,
|
||||
Admin::class,
|
||||
];
|
||||
|
||||
foreach ($profileTypes as $modelClass) {
|
||||
$profile = $modelClass::whereNotNull('deleted_at')
|
||||
->where('deleted_at', '>', $gracePeriodExpiry)
|
||||
->where('email', 'not like', 'removed-%@remove.ed')
|
||||
->where('name', $username)
|
||||
->first();
|
||||
|
||||
if ($profile) {
|
||||
return $profile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
648
app/Console/Commands/RestorePosts.php
Normal file
648
app/Console/Commands/RestorePosts.php
Normal file
@@ -0,0 +1,648 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Locations\CityLocale;
|
||||
use App\Models\Locations\CountryLocale;
|
||||
use App\Models\Locations\DistrictLocale;
|
||||
use App\Models\Locations\DivisionLocale;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTranslation;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use ZipArchive;
|
||||
|
||||
class RestorePosts extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'posts:restore
|
||||
{file : Path to the backup file (ZIP archive or JSON file)}
|
||||
{--profile-id= : Profile ID to assign as post owner (overrides active session)}
|
||||
{--profile-type= : Profile type (User, Organization, Bank, Admin) to assign as post owner}
|
||||
{--dry-run : Show what would be imported without making changes}
|
||||
{--skip-existing : Skip posts with duplicate slugs instead of failing}
|
||||
{--skip-media : Skip media restoration even if backup contains media files}
|
||||
{--select : Interactively select which posts to restore}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Restore posts, post_translations, meetings, and media from a backup file (ZIP or JSON)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$filePath = $this->argument('file');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$skipExisting = $this->option('skip-existing');
|
||||
$skipMedia = $this->option('skip-media');
|
||||
|
||||
// Validate file exists
|
||||
if (!File::exists($filePath)) {
|
||||
$this->error("Backup file not found: {$filePath}");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Determine file type and extract if necessary
|
||||
$isZip = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)) === 'zip';
|
||||
$extractDir = null;
|
||||
$backupData = null;
|
||||
|
||||
if ($isZip) {
|
||||
// Extract ZIP archive
|
||||
if (!class_exists('ZipArchive')) {
|
||||
$this->error('ZipArchive extension is not available. Install php-zip extension.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$extractDir = storage_path('app/temp/restore_' . uniqid());
|
||||
File::makeDirectory($extractDir, 0755, true);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($filePath) !== true) {
|
||||
$this->error("Failed to open ZIP archive: {$filePath}");
|
||||
File::deleteDirectory($extractDir);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$zip->extractTo($extractDir);
|
||||
$zip->close();
|
||||
|
||||
$this->info("Extracted ZIP archive to temporary directory");
|
||||
|
||||
// Read backup.json from extracted directory
|
||||
$jsonPath = "{$extractDir}/backup.json";
|
||||
if (!File::exists($jsonPath)) {
|
||||
$this->error("Invalid ZIP archive: missing backup.json");
|
||||
File::deleteDirectory($extractDir);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$json = File::get($jsonPath);
|
||||
} else {
|
||||
// Read JSON file directly
|
||||
$json = File::get($filePath);
|
||||
}
|
||||
|
||||
$backupData = json_decode($json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->error("Invalid JSON file: " . json_last_error_msg());
|
||||
if ($extractDir) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Validate backup format
|
||||
if (!isset($backupData['meta']) || !isset($backupData['posts'])) {
|
||||
$this->error("Invalid backup file format. Missing 'meta' or 'posts' keys.");
|
||||
if ($extractDir) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$includesMedia = $backupData['meta']['includes_media'] ?? false;
|
||||
$mediaCount = $backupData['meta']['counts']['media_files'] ?? 0;
|
||||
|
||||
$this->info("Backup file info:");
|
||||
$this->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['Version', $backupData['meta']['version'] ?? 'unknown'],
|
||||
['Created', $backupData['meta']['created_at'] ?? 'unknown'],
|
||||
['Source Database', $backupData['meta']['source_database'] ?? 'unknown'],
|
||||
['Posts', $backupData['meta']['counts']['posts'] ?? count($backupData['posts'])],
|
||||
['Translations', $backupData['meta']['counts']['post_translations'] ?? 'unknown'],
|
||||
['Meetings', $backupData['meta']['counts']['meetings'] ?? 'unknown'],
|
||||
['Media Files', $mediaCount],
|
||||
['Includes Media', $includesMedia ? 'Yes' : 'No'],
|
||||
]
|
||||
);
|
||||
|
||||
if ($includesMedia && $skipMedia) {
|
||||
$this->warn("Media restoration will be skipped (--skip-media flag)");
|
||||
}
|
||||
|
||||
// Handle post selection with --select flag
|
||||
$postsToRestore = $backupData['posts'];
|
||||
|
||||
if ($this->option('select')) {
|
||||
$baseLocale = config('app.locale');
|
||||
|
||||
// Build numbered list for display
|
||||
$tableRows = [];
|
||||
foreach ($backupData['posts'] as $index => $post) {
|
||||
$title = null;
|
||||
$locales = [];
|
||||
foreach ($post['translations'] ?? [] as $translation) {
|
||||
$locales[] = $translation['locale'];
|
||||
if ($translation['locale'] === $baseLocale) {
|
||||
$title = $translation['title'];
|
||||
}
|
||||
}
|
||||
if ($title === null && !empty($post['translations'])) {
|
||||
$title = $post['translations'][0]['title'];
|
||||
}
|
||||
|
||||
$indicators = [];
|
||||
if (!empty($post['meeting'])) $indicators[] = 'meeting';
|
||||
if (!empty($post['media'])) $indicators[] = 'media';
|
||||
$indicatorStr = $indicators ? ' [' . implode(', ', $indicators) . ']' : '';
|
||||
|
||||
$tableRows[] = [
|
||||
$index + 1,
|
||||
($title ?? 'Untitled') . $indicatorStr,
|
||||
implode(', ', $locales),
|
||||
];
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Available posts:');
|
||||
$this->table(['#', 'Title', 'Locales'], $tableRows);
|
||||
|
||||
$this->info("Enter post numbers to restore (comma-separated, ranges with dash, or 'all').");
|
||||
$this->info("Examples: 1,3,5 or 1-10 or 1-5,8,12-15 or all");
|
||||
$input = $this->ask('Selection');
|
||||
|
||||
if (strtolower(trim($input)) !== 'all') {
|
||||
$selectedIndices = $this->parseSelection($input, count($backupData['posts']));
|
||||
|
||||
if (empty($selectedIndices)) {
|
||||
$this->error('No valid posts selected.');
|
||||
if ($extractDir) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$postsToRestore = [];
|
||||
foreach ($selectedIndices as $idx) {
|
||||
$postsToRestore[] = $backupData['posts'][$idx];
|
||||
}
|
||||
|
||||
$this->info("Selected " . count($postsToRestore) . " of " . count($backupData['posts']) . " posts.");
|
||||
}
|
||||
}
|
||||
|
||||
// Determine profile for post ownership
|
||||
$profileId = $this->option('profile-id');
|
||||
$profileType = $this->option('profile-type');
|
||||
|
||||
if ($profileId && $profileType) {
|
||||
// Use provided profile
|
||||
$profileType = $this->resolveProfileType($profileType);
|
||||
if (!$profileType) {
|
||||
$this->error("Invalid profile type. Use: User, Organization, Bank, or Admin");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} else {
|
||||
// Try to get from session (won't work in CLI, but check anyway)
|
||||
$profileId = session('activeProfileId');
|
||||
$profileType = session('activeProfileType');
|
||||
|
||||
if (!$profileId || !$profileType) {
|
||||
$this->error("No active profile in session. Please provide --profile-id and --profile-type options.");
|
||||
$this->info("Example: php artisan posts:restore backup.json --profile-id=1 --profile-type=User");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate profile exists
|
||||
if (!class_exists($profileType)) {
|
||||
$this->error("Profile type class not found: {$profileType}");
|
||||
if ($extractDir) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$profile = $profileType::find($profileId);
|
||||
if (!$profile) {
|
||||
$this->error("Profile not found: {$profileType} with ID {$profileId}");
|
||||
if ($extractDir) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Posts will be assigned to: {$profile->name} ({$profileType} #{$profileId})");
|
||||
|
||||
// Build category type => id lookup for the target database
|
||||
$categoryLookup = Category::pluck('id', 'type')->toArray();
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn("DRY RUN MODE - No changes will be made");
|
||||
}
|
||||
|
||||
if (!$dryRun && !$this->confirm("Do you want to proceed with the restore?")) {
|
||||
$this->info("Restore cancelled.");
|
||||
if ($extractDir) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'posts_created' => 0,
|
||||
'posts_skipped' => 0,
|
||||
'posts_overwritten' => 0,
|
||||
'translations_created' => 0,
|
||||
'meetings_created' => 0,
|
||||
'media_restored' => 0,
|
||||
'media_skipped' => 0,
|
||||
'category_not_found' => 0,
|
||||
];
|
||||
|
||||
// Track "all" choices for duplicate handling
|
||||
$skipAll = null;
|
||||
|
||||
$bar = $this->output->createProgressBar(count($postsToRestore));
|
||||
$bar->start();
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($postsToRestore as $postData) {
|
||||
// Look up category_id by category_type
|
||||
$categoryId = null;
|
||||
if (!empty($postData['category_type'])) {
|
||||
$categoryId = $categoryLookup[$postData['category_type']] ?? null;
|
||||
if ($categoryId === null) {
|
||||
$this->newLine();
|
||||
$this->warn("Category type not found: {$postData['category_type']}");
|
||||
$stats['category_not_found']++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing slugs
|
||||
if (!empty($postData['translations'])) {
|
||||
$existingSlugs = PostTranslation::withTrashed()
|
||||
->whereIn('slug', array_column($postData['translations'], 'slug'))
|
||||
->pluck('slug')
|
||||
->toArray();
|
||||
|
||||
if (!empty($existingSlugs)) {
|
||||
$this->newLine();
|
||||
$this->warn("Duplicate slug(s) found: " . implode(', ', $existingSlugs));
|
||||
|
||||
// Determine action based on flags or prompt
|
||||
$action = $skipAll ?? null;
|
||||
|
||||
if ($action === null && !$skipExisting) {
|
||||
$action = $this->choice(
|
||||
'What would you like to do?',
|
||||
[
|
||||
'skip' => 'Skip this post',
|
||||
'overwrite' => 'Overwrite existing post(s)',
|
||||
'skip_all' => 'Skip all duplicates',
|
||||
'overwrite_all' => 'Overwrite all duplicates',
|
||||
],
|
||||
'skip'
|
||||
);
|
||||
|
||||
if ($action === 'skip_all') {
|
||||
$skipAll = 'skip';
|
||||
$action = 'skip';
|
||||
} elseif ($action === 'overwrite_all') {
|
||||
$skipAll = 'overwrite';
|
||||
$action = 'overwrite';
|
||||
}
|
||||
} elseif ($skipExisting) {
|
||||
$action = 'skip';
|
||||
}
|
||||
|
||||
if ($action === 'skip') {
|
||||
$stats['posts_skipped']++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
} elseif ($action === 'overwrite') {
|
||||
// Delete existing translations and their posts if they become empty
|
||||
$existingTranslations = PostTranslation::withTrashed()
|
||||
->whereIn('slug', $existingSlugs)
|
||||
->get();
|
||||
|
||||
foreach ($existingTranslations as $existingTranslation) {
|
||||
$postId = $existingTranslation->post_id;
|
||||
$existingTranslation->forceDelete();
|
||||
|
||||
// Check if post has no more translations and delete it too
|
||||
$remainingTranslations = PostTranslation::withTrashed()
|
||||
->where('post_id', $postId)
|
||||
->count();
|
||||
|
||||
if ($remainingTranslations === 0) {
|
||||
$existingPost = Post::withTrashed()->find($postId);
|
||||
if ($existingPost) {
|
||||
// Delete related meetings first
|
||||
Meeting::withTrashed()->where('post_id', $postId)->forceDelete();
|
||||
$existingPost->forceDelete();
|
||||
}
|
||||
}
|
||||
}
|
||||
$stats['posts_overwritten']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
// Create post with profile ownership
|
||||
$post = new Post();
|
||||
$post->postable_id = $profileId;
|
||||
$post->postable_type = $profileType;
|
||||
$post->category_id = $categoryId;
|
||||
// Don't set love_reactant_id - let PostObserver register it as reactant
|
||||
$post->author_id = $postData['author_id'];
|
||||
$post->author_model = $postData['author_model'];
|
||||
$post->created_at = $postData['created_at'] ? new \DateTime($postData['created_at']) : now();
|
||||
$post->updated_at = $postData['updated_at'] ? new \DateTime($postData['updated_at']) : now();
|
||||
$post->save();
|
||||
|
||||
// Ensure post is registered as reactant (in case observer didn't fire)
|
||||
if (!$post->isRegisteredAsLoveReactant()) {
|
||||
$post->registerAsLoveReactant();
|
||||
}
|
||||
|
||||
// Create translations
|
||||
foreach ($postData['translations'] as $translationData) {
|
||||
$translation = new PostTranslation();
|
||||
$translation->post_id = $post->id;
|
||||
$translation->locale = $translationData['locale'];
|
||||
$translation->slug = $translationData['slug'];
|
||||
$translation->title = $translationData['title'];
|
||||
$translation->excerpt = $translationData['excerpt'];
|
||||
$translation->content = $translationData['content'];
|
||||
$translation->status = $translationData['status'];
|
||||
$translation->updated_by_user_id = $translationData['updated_by_user_id'];
|
||||
$translation->from = $translationData['from'] ? new \DateTime($translationData['from']) : null;
|
||||
$translation->till = $translationData['till'] ? new \DateTime($translationData['till']) : null;
|
||||
$translation->created_at = $translationData['created_at'] ? new \DateTime($translationData['created_at']) : now();
|
||||
$translation->updated_at = $translationData['updated_at'] ? new \DateTime($translationData['updated_at']) : now();
|
||||
$translation->save();
|
||||
|
||||
$stats['translations_created']++;
|
||||
}
|
||||
|
||||
// Create meeting (hasOne relationship)
|
||||
if (!empty($postData['meeting'])) {
|
||||
$meetingData = $postData['meeting'];
|
||||
|
||||
// Look up meetingable by name and type
|
||||
$meetingableId = null;
|
||||
$meetingableType = null;
|
||||
// Whitelist of allowed meetingable types to prevent arbitrary class instantiation
|
||||
$allowedMeetingableTypes = [
|
||||
\App\Models\User::class,
|
||||
\App\Models\Organization::class,
|
||||
\App\Models\Bank::class,
|
||||
];
|
||||
if (!empty($meetingData['meetingable_type']) && !empty($meetingData['meetingable_name'])) {
|
||||
$meetingableType = $meetingData['meetingable_type'];
|
||||
if (in_array($meetingableType, $allowedMeetingableTypes, true)) {
|
||||
$meetingable = $meetingableType::where('name', $meetingData['meetingable_name'])->first();
|
||||
if ($meetingable) {
|
||||
$meetingableId = $meetingable->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$meeting = new Meeting();
|
||||
$meeting->post_id = $post->id;
|
||||
$meeting->meetingable_id = $meetingableId;
|
||||
$meeting->meetingable_type = $meetingableId ? $meetingableType : null;
|
||||
$meeting->venue = $meetingData['venue'];
|
||||
$meeting->address = $meetingData['address'];
|
||||
$meeting->price = $meetingData['price'];
|
||||
$meeting->based_on_quantity = $meetingData['based_on_quantity'];
|
||||
$meeting->transaction_type_id = $meetingData['transaction_type_id'];
|
||||
$meeting->status = $meetingData['status'];
|
||||
$meeting->from = $meetingData['from'] ? new \DateTime($meetingData['from']) : null;
|
||||
$meeting->till = $meetingData['till'] ? new \DateTime($meetingData['till']) : null;
|
||||
$meeting->created_at = $meetingData['created_at'] ? new \DateTime($meetingData['created_at']) : now();
|
||||
$meeting->updated_at = $meetingData['updated_at'] ? new \DateTime($meetingData['updated_at']) : now();
|
||||
$meeting->save();
|
||||
|
||||
// Create location if location data exists
|
||||
if (!empty($meetingData['location'])) {
|
||||
$locationIds = $this->lookupLocationIds($meetingData['location']);
|
||||
if ($locationIds['country_id'] || $locationIds['division_id'] || $locationIds['city_id'] || $locationIds['district_id']) {
|
||||
$location = new Location();
|
||||
$location->locatable_id = $meeting->id;
|
||||
$location->locatable_type = Meeting::class;
|
||||
$location->country_id = $locationIds['country_id'];
|
||||
$location->division_id = $locationIds['division_id'];
|
||||
$location->city_id = $locationIds['city_id'];
|
||||
$location->district_id = $locationIds['district_id'];
|
||||
$location->save();
|
||||
}
|
||||
}
|
||||
|
||||
$stats['meetings_created']++;
|
||||
}
|
||||
|
||||
// Restore media if available and not skipped
|
||||
if (!$skipMedia && $extractDir && !empty($postData['media'])) {
|
||||
$mediaData = $postData['media'];
|
||||
$mediaPath = "{$extractDir}/{$mediaData['archive_path']}";
|
||||
|
||||
if (File::exists($mediaPath)) {
|
||||
try {
|
||||
$media = $post->addMedia($mediaPath)
|
||||
->usingName($mediaData['name'])
|
||||
->usingFileName($mediaData['file_name'])
|
||||
->withCustomProperties($mediaData['custom_properties'] ?? [])
|
||||
->toMediaCollection('posts');
|
||||
|
||||
// Dispatch conversion job to queue
|
||||
$conversionCollection = \Spatie\MediaLibrary\Conversions\ConversionCollection::createForMedia($media);
|
||||
if ($conversionCollection->isNotEmpty()) {
|
||||
dispatch(new \Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob($conversionCollection, $media, false))
|
||||
->onQueue('low');
|
||||
}
|
||||
|
||||
$stats['media_restored']++;
|
||||
} catch (\Exception $e) {
|
||||
$this->newLine();
|
||||
$this->warn("Failed to restore media for post {$post->id}: " . $e->getMessage());
|
||||
$stats['media_skipped']++;
|
||||
}
|
||||
} else {
|
||||
$this->newLine();
|
||||
$this->warn("Media file not found in archive: {$mediaData['archive_path']}");
|
||||
$stats['media_skipped']++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dry run - just count
|
||||
$stats['translations_created'] += count($postData['translations']);
|
||||
$stats['meetings_created'] += !empty($postData['meeting']) ? 1 : 0;
|
||||
if (!empty($postData['media'])) {
|
||||
$stats['media_restored']++;
|
||||
}
|
||||
}
|
||||
|
||||
$stats['posts_created']++;
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
DB::commit();
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->newLine();
|
||||
$this->error("Restore failed: " . $e->getMessage());
|
||||
if ($extractDir) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Clean up extracted files
|
||||
if ($extractDir) {
|
||||
File::deleteDirectory($extractDir);
|
||||
$this->info("Cleaned up temporary files");
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$this->info($dryRun ? "Dry run completed!" : "Restore completed successfully!");
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Posts Created', $stats['posts_created']],
|
||||
['Posts Skipped', $stats['posts_skipped']],
|
||||
['Posts Overwritten', $stats['posts_overwritten']],
|
||||
['Translations Created', $stats['translations_created']],
|
||||
['Meetings Created', $stats['meetings_created']],
|
||||
['Media Restored', $stats['media_restored']],
|
||||
['Media Skipped', $stats['media_skipped']],
|
||||
['Categories Not Found', $stats['category_not_found']],
|
||||
]
|
||||
);
|
||||
|
||||
if ($stats['category_not_found'] > 0) {
|
||||
$this->warn("Some posts were created without a category. You may need to assign categories manually.");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve profile type string to full class name.
|
||||
*/
|
||||
private function resolveProfileType(string $type): ?string
|
||||
{
|
||||
$typeMap = [
|
||||
'user' => \App\Models\User::class,
|
||||
'organization' => \App\Models\Organization::class,
|
||||
'bank' => \App\Models\Bank::class,
|
||||
'admin' => \App\Models\Admin::class,
|
||||
];
|
||||
|
||||
$normalized = strtolower(trim($type));
|
||||
|
||||
// Handle full class names - only allow known model classes
|
||||
if (str_contains($type, '\\')) {
|
||||
return in_array($type, $typeMap, true) ? $type : null;
|
||||
}
|
||||
|
||||
return $typeMap[$normalized] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up location IDs by names in the app's base locale.
|
||||
* Returns null for any location component that cannot be found.
|
||||
*/
|
||||
private function lookupLocationIds(array $locationData): array
|
||||
{
|
||||
$baseLocale = config('app.locale');
|
||||
|
||||
$result = [
|
||||
'country_id' => null,
|
||||
'division_id' => null,
|
||||
'city_id' => null,
|
||||
'district_id' => null,
|
||||
];
|
||||
|
||||
// Look up country by name
|
||||
if (!empty($locationData['country_name'])) {
|
||||
$countryLocale = CountryLocale::withoutGlobalScopes()
|
||||
->where('name', $locationData['country_name'])
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$result['country_id'] = $countryLocale?->country_id;
|
||||
}
|
||||
|
||||
// Look up division by name
|
||||
if (!empty($locationData['division_name'])) {
|
||||
$divisionLocale = DivisionLocale::withoutGlobalScopes()
|
||||
->where('name', $locationData['division_name'])
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$result['division_id'] = $divisionLocale?->division_id;
|
||||
}
|
||||
|
||||
// Look up city by name
|
||||
if (!empty($locationData['city_name'])) {
|
||||
$cityLocale = CityLocale::withoutGlobalScopes()
|
||||
->where('name', $locationData['city_name'])
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$result['city_id'] = $cityLocale?->city_id;
|
||||
}
|
||||
|
||||
// Look up district by name
|
||||
if (!empty($locationData['district_name'])) {
|
||||
$districtLocale = DistrictLocale::withoutGlobalScopes()
|
||||
->where('name', $locationData['district_name'])
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$result['district_id'] = $districtLocale?->district_id;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a user selection string like "1,3,5-10" into an array of 0-based indices.
|
||||
*/
|
||||
private function parseSelection(string $input, int $total): array
|
||||
{
|
||||
$indices = [];
|
||||
$parts = preg_split('/\s*,\s*/', trim($input));
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
if (preg_match('/^(\d+)-(\d+)$/', $part, $matches)) {
|
||||
$start = max(1, (int) $matches[1]);
|
||||
$end = min($total, (int) $matches[2]);
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
$indices[] = $i - 1;
|
||||
}
|
||||
} elseif (preg_match('/^\d+$/', $part)) {
|
||||
$num = (int) $part;
|
||||
if ($num >= 1 && $num <= $total) {
|
||||
$indices[] = $num - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($indices));
|
||||
}
|
||||
}
|
||||
181
app/Console/Commands/RetryFailedMailings.php
Normal file
181
app/Console/Commands/RetryFailedMailings.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\SendBulkMailJob;
|
||||
use App\Models\Mailing;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RetryFailedMailings extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'mailings:retry-failed
|
||||
{--mailing-id= : Specific mailing ID to retry}
|
||||
{--hours= : Retry mailings failed within this many hours (default from config)}
|
||||
{--dry-run : Show what would be retried without actually retrying}
|
||||
{--force : Force retry even if within normal retry window}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Retry failed email mailings that are outside their normal retry window';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$mailingId = $this->option('mailing-id');
|
||||
$hours = $this->option('hours') ?: timebank_config('bulk_mail.abandon_after_hours', 72);
|
||||
$dryRun = $this->option('dry-run');
|
||||
$force = $this->option('force');
|
||||
|
||||
$this->info("Looking for failed mailings" . ($mailingId ? " (ID: {$mailingId})" : " from the last {$hours} hours") . "...");
|
||||
|
||||
// Build query for mailings with failures
|
||||
$query = Mailing::where('status', 'sent')
|
||||
->where('failed_count', '>', 0);
|
||||
|
||||
if ($mailingId) {
|
||||
$query->where('id', $mailingId);
|
||||
} else {
|
||||
$query->where('sent_at', '>=', now()->subHours($hours));
|
||||
}
|
||||
|
||||
$failedMailings = $query->get();
|
||||
|
||||
if ($failedMailings->isEmpty()) {
|
||||
$this->info('No failed mailings found to retry.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found {$failedMailings->count()} mailings with failures:");
|
||||
|
||||
foreach ($failedMailings as $mailing) {
|
||||
$this->line("- Mailing #{$mailing->id}: {$mailing->title}");
|
||||
$this->line(" Failed: {$mailing->failed_count}, Sent: {$mailing->sent_count}, Total: {$mailing->recipients_count}");
|
||||
$this->line(" Sent at: {$mailing->sent_at}");
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("\n[DRY RUN] Would retry the above mailings. Use --force to actually retry.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!$force && !$this->confirm('Do you want to retry these failed mailings?')) {
|
||||
$this->info('Operation cancelled.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalRetried = 0;
|
||||
|
||||
foreach ($failedMailings as $mailing) {
|
||||
$this->info("\nRetrying mailing #{$mailing->id}: {$mailing->title}");
|
||||
|
||||
$retriedCount = $this->retryFailedMailing($mailing, $force);
|
||||
$totalRetried += $retriedCount;
|
||||
|
||||
if ($retriedCount > 0) {
|
||||
$this->info("Queued {$retriedCount} retry jobs for mailing #{$mailing->id}");
|
||||
} else {
|
||||
$this->warn("No recipients to retry for mailing #{$mailing->id}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("\nCompleted! Total retry jobs queued: {$totalRetried}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a specific failed mailing
|
||||
*/
|
||||
protected function retryFailedMailing(Mailing $mailing, bool $force = false): int
|
||||
{
|
||||
// Check if mailing is still within automatic retry window
|
||||
$abandonAfterHours = timebank_config('bulk_mail.abandon_after_hours', 72);
|
||||
$retryWindowExpired = $mailing->sent_at->addHours($abandonAfterHours)->isPast();
|
||||
|
||||
if (!$force && !$retryWindowExpired) {
|
||||
$this->warn("Mailing #{$mailing->id} is still within automatic retry window. Use --force to override.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get all recipients and group by locale
|
||||
$recipientsByLocale = $this->getRecipientsGroupedByLocale($mailing);
|
||||
|
||||
if (empty($recipientsByLocale)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$jobsQueued = 0;
|
||||
|
||||
// Dispatch retry jobs for each locale
|
||||
foreach ($recipientsByLocale as $locale => $recipients) {
|
||||
if (!empty($recipients)) {
|
||||
$contentBlocks = $mailing->getContentBlocksForLocale($locale);
|
||||
|
||||
SendBulkMailJob::dispatch($mailing, $locale, $contentBlocks, collect($recipients))
|
||||
->onQueue('emails');
|
||||
|
||||
$jobsQueued++;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset failure count to allow fresh tracking
|
||||
if ($jobsQueued > 0) {
|
||||
$mailing->update([
|
||||
'failed_count' => 0,
|
||||
'status' => 'sending' // Reset to sending status
|
||||
]);
|
||||
}
|
||||
|
||||
return $jobsQueued;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipients grouped by locale for retry
|
||||
* Note: This is a simplified approach - in a production system you might want to track
|
||||
* individual recipient failures more precisely
|
||||
*/
|
||||
protected function getRecipientsGroupedByLocale(Mailing $mailing): array
|
||||
{
|
||||
// Get all potential recipients again
|
||||
$allRecipients = $mailing->getRecipientsQuery()->get();
|
||||
|
||||
if ($allRecipients->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group by language preference
|
||||
$recipientsByLocale = [];
|
||||
|
||||
foreach ($allRecipients as $recipient) {
|
||||
$locale = $recipient->lang_preference ?? timebank_config('base_language', 'en');
|
||||
|
||||
// Only include locales that have content blocks
|
||||
$availableLocales = $mailing->getAvailablePostLocales();
|
||||
if (in_array($locale, $availableLocales)) {
|
||||
$recipientsByLocale[$locale][] = $recipient;
|
||||
} else {
|
||||
// Check if fallback is enabled
|
||||
if (timebank_config('bulk_mail.use_fallback_locale', true)) {
|
||||
$fallbackLocale = timebank_config('bulk_mail.fallback_locale', 'en');
|
||||
if (in_array($fallbackLocale, $availableLocales)) {
|
||||
$recipientsByLocale[$fallbackLocale][] = $recipient;
|
||||
}
|
||||
}
|
||||
// If fallback is disabled or fallback locale not available, skip this recipient
|
||||
}
|
||||
}
|
||||
|
||||
return $recipientsByLocale;
|
||||
}
|
||||
}
|
||||
65
app/Console/Commands/ScoutReindexCommand.php
Normal file
65
app/Console/Commands/ScoutReindexCommand.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
// Create this file: app/Console/Commands/ScoutReindexCommand.php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ScoutReindexCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'scout:reindex-model
|
||||
{model : The model class name (e.g., User, Organization)}
|
||||
{--id= : Specific model ID to reindex}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Manually trigger Scout reindexing for specific models';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$modelName = $this->argument('model');
|
||||
$modelId = $this->option('id');
|
||||
|
||||
// Build full model class name
|
||||
$modelClass = "App\\Models\\{$modelName}";
|
||||
|
||||
if (!class_exists($modelClass)) {
|
||||
$this->error("Model {$modelClass} does not exist.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($modelId) {
|
||||
// Reindex specific model
|
||||
$model = $modelClass::find($modelId);
|
||||
if (!$model) {
|
||||
$this->error("Model {$modelName} with ID {$modelId} not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Reindexing {$modelName} #{$modelId}...");
|
||||
|
||||
// Force reindex the model
|
||||
$model->searchable();
|
||||
|
||||
$this->info("✅ Successfully reindexed {$modelName} #{$modelId}");
|
||||
} else {
|
||||
$this->error("Please specify --id=X");
|
||||
return 1;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Failed to reindex: " . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
457
app/Console/Commands/SendTestEmail.php
Normal file
457
app/Console/Commands/SendTestEmail.php
Normal file
@@ -0,0 +1,457 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Account;
|
||||
use App\Mail\CallBlockedMail;
|
||||
use App\Mail\CallExpiredMail;
|
||||
use App\Mail\CallExpiringMail;
|
||||
use App\Mail\InactiveProfileWarning1Mail;
|
||||
use App\Models\Call;
|
||||
use App\Mail\InactiveProfileWarning2Mail;
|
||||
use App\Mail\InactiveProfileWarningFinalMail;
|
||||
use App\Mail\UserDeletedMail;
|
||||
use App\Mail\TransferReceived;
|
||||
use App\Mail\ProfileLinkChangedMail;
|
||||
use App\Mail\ProfileEditedByAdminMail;
|
||||
use App\Mail\VerifyProfileEmailMailable;
|
||||
use App\Mail\ReservationCreatedMail;
|
||||
use App\Mail\ReservationCancelledMail;
|
||||
use App\Mail\ReservationUpdateMail;
|
||||
use App\Mail\ReactionCreatedMail;
|
||||
use App\Mail\TagAddedMail;
|
||||
|
||||
class SendTestEmail extends Command
|
||||
{
|
||||
protected $signature = 'email:send-test
|
||||
{--type= : Email type to send (use --list to see all types)}
|
||||
{--receiver= : Receiver type (user, organization, admin, bank)}
|
||||
{--id= : Receiver ID}
|
||||
{--list : List all available email types}
|
||||
{--queue : Send via queue instead of immediately}';
|
||||
|
||||
protected $description = 'Send test mailing transactional emails for testing and review';
|
||||
|
||||
protected $emailTypes = [
|
||||
'inactive-warning-1' => [
|
||||
'class' => InactiveProfileWarning1Mail::class,
|
||||
'description' => 'Inactive profile warning 1 (first warning)',
|
||||
'supports' => ['user', 'organization'],
|
||||
],
|
||||
'inactive-warning-2' => [
|
||||
'class' => InactiveProfileWarning2Mail::class,
|
||||
'description' => 'Inactive profile warning 2 (second warning)',
|
||||
'supports' => ['user', 'organization'],
|
||||
],
|
||||
'inactive-warning-final' => [
|
||||
'class' => InactiveProfileWarningFinalMail::class,
|
||||
'description' => 'Inactive Profile Final Warning (last warning)',
|
||||
'supports' => ['user', 'organization'],
|
||||
],
|
||||
'user-deleted' => [
|
||||
'class' => UserDeletedMail::class,
|
||||
'description' => 'User Deleted Notification',
|
||||
'supports' => ['user'],
|
||||
],
|
||||
'transfer-received' => [
|
||||
'class' => TransferReceived::class,
|
||||
'description' => 'Transfer/Payment Received Notification',
|
||||
'supports' => ['user', 'organization'],
|
||||
],
|
||||
'profile-link-changed' => [
|
||||
'class' => ProfileLinkChangedMail::class,
|
||||
'description' => 'Profile Link/Name Changed Notification',
|
||||
'supports' => ['user', 'organization', 'admin', 'bank'],
|
||||
],
|
||||
'profile-edited-by-admin' => [
|
||||
'class' => ProfileEditedByAdminMail::class,
|
||||
'description' => 'Profile Edited by Admin Notification',
|
||||
'supports' => ['user', 'organization'],
|
||||
],
|
||||
'verify-email' => [
|
||||
'class' => VerifyProfileEmailMailable::class,
|
||||
'description' => 'Email Verification Request',
|
||||
'supports' => ['user', 'organization'],
|
||||
],
|
||||
'reservation-created' => [
|
||||
'class' => ReservationCreatedMail::class,
|
||||
'description' => 'Reservation Created Notification',
|
||||
'supports' => ['user', 'organization'],
|
||||
],
|
||||
'reservation-cancelled' => [
|
||||
'class' => ReservationCancelledMail::class,
|
||||
'description' => 'Reservation Cancelled Notification',
|
||||
'supports' => ['user', 'organization'],
|
||||
],
|
||||
'reservation-updated' => [
|
||||
'class' => ReservationUpdateMail::class,
|
||||
'description' => 'Reservation Updated Notification',
|
||||
'supports' => ['user', 'organization'],
|
||||
],
|
||||
'reaction-created' => [
|
||||
'class' => ReactionCreatedMail::class,
|
||||
'description' => 'Reaction/Comment Created Notification',
|
||||
'supports' => ['user', 'organization'],
|
||||
],
|
||||
'tag-added' => [
|
||||
'class' => TagAddedMail::class,
|
||||
'description' => 'Tag Added to Profile Notification',
|
||||
'supports' => ['user', 'organization'],
|
||||
],
|
||||
'call-expired' => [
|
||||
'class' => CallExpiredMail::class,
|
||||
'description' => 'Call Expired Notification',
|
||||
'supports' => ['user', 'organization', 'bank'],
|
||||
],
|
||||
'call-expiring' => [
|
||||
'class' => CallExpiringMail::class,
|
||||
'description' => 'Call Expiring Soon Warning',
|
||||
'supports' => ['user', 'organization', 'bank'],
|
||||
],
|
||||
'call-blocked' => [
|
||||
'class' => CallBlockedMail::class,
|
||||
'description' => 'Call Blocked by Admin Notification',
|
||||
'supports' => ['user', 'organization', 'bank'],
|
||||
],
|
||||
];
|
||||
|
||||
public function handle()
|
||||
{
|
||||
if ($this->option('list')) {
|
||||
return $this->listEmailTypes();
|
||||
}
|
||||
|
||||
$type = $this->option('type');
|
||||
$receiverType = $this->option('receiver');
|
||||
$receiverId = $this->option('id');
|
||||
|
||||
// Interactive mode if no options provided
|
||||
if (!$type || !$receiverType || !$receiverId) {
|
||||
return $this->interactiveMode();
|
||||
}
|
||||
|
||||
return $this->sendEmail($type, $receiverType, $receiverId);
|
||||
}
|
||||
|
||||
protected function listEmailTypes()
|
||||
{
|
||||
$this->info('Available Email Types:');
|
||||
$this->newLine();
|
||||
|
||||
foreach ($this->emailTypes as $key => $config) {
|
||||
$supports = implode(', ', $config['supports']);
|
||||
$this->line(" <fg=cyan>{$key}</>");
|
||||
$this->line(" Description: {$config['description']}");
|
||||
$this->line(" Supports: {$supports}");
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->info('Usage Example:');
|
||||
$this->line(' php artisan email:send-test --type=inactive-warning-1 --receiver=user --id=102');
|
||||
$this->newLine();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function interactiveMode()
|
||||
{
|
||||
$this->info('📧 Test Email Sender - Interactive Mode');
|
||||
$this->newLine();
|
||||
|
||||
// Select email type
|
||||
$typeChoices = array_map(
|
||||
fn ($key, $config) => "{$key} - {$config['description']}",
|
||||
array_keys($this->emailTypes),
|
||||
array_values($this->emailTypes)
|
||||
);
|
||||
|
||||
$selectedIndex = array_search(
|
||||
$this->choice('Select email type', $typeChoices),
|
||||
$typeChoices
|
||||
);
|
||||
$type = array_keys($this->emailTypes)[$selectedIndex];
|
||||
|
||||
// Select receiver type
|
||||
$supports = $this->emailTypes[$type]['supports'];
|
||||
$receiverType = $this->choice('Select receiver type', $supports);
|
||||
|
||||
// Enter receiver ID
|
||||
$receiverId = $this->ask('Enter receiver ID');
|
||||
|
||||
return $this->sendEmail($type, $receiverType, $receiverId);
|
||||
}
|
||||
|
||||
protected function sendEmail($type, $receiverType, $receiverId)
|
||||
{
|
||||
if (!isset($this->emailTypes[$type])) {
|
||||
$this->error("Invalid email type: {$type}");
|
||||
$this->info('Use --list to see all available types');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$config = $this->emailTypes[$type];
|
||||
|
||||
if (!in_array($receiverType, $config['supports'])) {
|
||||
$this->error("Email type '{$type}' does not support receiver type '{$receiverType}'");
|
||||
$this->info('Supported types: ' . implode(', ', $config['supports']));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get receiver profile
|
||||
$receiver = $this->getReceiver($receiverType, $receiverId);
|
||||
if (!$receiver) {
|
||||
$this->error("Receiver not found: {$receiverType} #{$receiverId}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Sending '{$type}' email to {$receiver->name} ({$receiver->email})");
|
||||
$this->newLine();
|
||||
|
||||
try {
|
||||
$mailable = $this->buildMailable($type, $receiver, $receiverType);
|
||||
|
||||
if ($this->option('queue')) {
|
||||
Mail::to($receiver->email)->queue($mailable);
|
||||
$this->info('✅ Email queued successfully');
|
||||
$this->line('Run queue worker: php artisan queue:work --stop-when-empty');
|
||||
} else {
|
||||
Mail::to($receiver->email)->send($mailable);
|
||||
$this->info('✅ Email sent successfully');
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line("Recipient: {$receiver->email}");
|
||||
$this->line("Profile: {$receiver->name}");
|
||||
$this->line("Language: " . ($receiver->lang_preference ?? 'en'));
|
||||
|
||||
return 0;
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to send email: ' . $e->getMessage());
|
||||
$this->line($e->getTraceAsString());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getReceiver($type, $id)
|
||||
{
|
||||
return match($type) {
|
||||
'user' => User::find($id),
|
||||
'organization' => Organization::find($id),
|
||||
'admin' => Admin::find($id),
|
||||
'bank' => Bank::find($id),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
protected function buildMailable($type, $receiver, $receiverType)
|
||||
{
|
||||
// Get test data
|
||||
$accounts = $this->getAccountsData($receiver);
|
||||
$totalBalance = $this->getTotalBalance($accounts);
|
||||
|
||||
return match($type) {
|
||||
'inactive-warning-1' => new InactiveProfileWarning1Mail(
|
||||
$receiver,
|
||||
ucfirst($receiverType),
|
||||
'2 weeks',
|
||||
14,
|
||||
$accounts,
|
||||
$totalBalance,
|
||||
351
|
||||
),
|
||||
'inactive-warning-2' => new InactiveProfileWarning2Mail(
|
||||
$receiver,
|
||||
ucfirst($receiverType),
|
||||
'1 week',
|
||||
7,
|
||||
$accounts,
|
||||
$totalBalance,
|
||||
358
|
||||
),
|
||||
'inactive-warning-final' => new InactiveProfileWarningFinalMail(
|
||||
$receiver,
|
||||
ucfirst($receiverType),
|
||||
'24 hours',
|
||||
1,
|
||||
$accounts,
|
||||
$totalBalance,
|
||||
365
|
||||
),
|
||||
'user-deleted' => new UserDeletedMail(
|
||||
$receiver,
|
||||
$accounts,
|
||||
$totalBalance,
|
||||
$this->getTransferTargetAccount()
|
||||
),
|
||||
'transfer-received' => $this->buildTransferReceivedMail($receiver),
|
||||
'profile-link-changed' => new ProfileLinkChangedMail(
|
||||
$receiver,
|
||||
$this->getLinkedProfileForTest($receiver),
|
||||
'attached'
|
||||
),
|
||||
'profile-edited-by-admin' => new ProfileEditedByAdminMail(
|
||||
$receiver,
|
||||
'Test Admin',
|
||||
'Updated profile information for testing purposes'
|
||||
),
|
||||
'verify-email' => new VerifyProfileEmailMailable(
|
||||
$receiver->email,
|
||||
url('/verify-email/' . base64_encode($receiver->email))
|
||||
),
|
||||
'reservation-created' => $this->buildReservationMail($receiver, ReservationCreatedMail::class),
|
||||
'reservation-cancelled' => $this->buildReservationMail($receiver, ReservationCancelledMail::class),
|
||||
'reservation-updated' => $this->buildReservationMail($receiver, ReservationUpdateMail::class),
|
||||
'reaction-created' => $this->buildReactionMail($receiver),
|
||||
'tag-added' => $this->buildTagAddedMail($receiver),
|
||||
'call-expired' => $this->buildCallExpiredMail($receiver, $receiverType),
|
||||
'call-expiring' => $this->buildCallExpiringMail($receiver, $receiverType),
|
||||
'call-blocked' => $this->buildCallBlockedMail($receiver, $receiverType),
|
||||
default => throw new \Exception("Mailable builder not implemented for type: {$type}"),
|
||||
};
|
||||
}
|
||||
|
||||
protected function getAccountsData($profile)
|
||||
{
|
||||
$accounts = [];
|
||||
$profileAccounts = $profile->accounts()->active()->notRemoved()->get();
|
||||
|
||||
foreach ($profileAccounts as $account) {
|
||||
\Cache::forget("account_balance_{$account->id}");
|
||||
$accounts[] = [
|
||||
'id' => $account->id,
|
||||
'name' => $account->name,
|
||||
'balance' => $account->balance,
|
||||
'balanceFormatted' => tbFormat($account->balance),
|
||||
];
|
||||
}
|
||||
|
||||
return $accounts;
|
||||
}
|
||||
|
||||
protected function getTotalBalance($accounts)
|
||||
{
|
||||
return array_sum(array_column($accounts, 'balance'));
|
||||
}
|
||||
|
||||
protected function getTransferTargetAccount()
|
||||
{
|
||||
// Get a random organization account or create test data
|
||||
$account = Account::whereHasMorph('accountable', [Organization::class])
|
||||
->active()
|
||||
->notRemoved()
|
||||
->first();
|
||||
|
||||
return $account ? [
|
||||
'id' => $account->id,
|
||||
'name' => $account->name,
|
||||
] : [
|
||||
'id' => 1,
|
||||
'name' => 'Test Organization Account',
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildTransferReceivedMail($receiver)
|
||||
{
|
||||
$senderAccount = $receiver->accounts()->active()->notRemoved()->first();
|
||||
|
||||
if (!$senderAccount) {
|
||||
throw new \Exception('Receiver has no active accounts');
|
||||
}
|
||||
|
||||
return new TransferReceived(
|
||||
$receiver->name,
|
||||
120, // 2 hours in minutes
|
||||
tbFormat(120),
|
||||
'Test Transfer',
|
||||
$senderAccount->name,
|
||||
$receiver->email,
|
||||
url('/profile/' . $receiver->name)
|
||||
);
|
||||
}
|
||||
|
||||
protected function buildReservationMail($receiver, $mailClass)
|
||||
{
|
||||
$postTitle = 'Test Post - ' . $mailClass;
|
||||
$postOwner = 'Test Post Owner';
|
||||
$postUrl = url('/posts/test-post');
|
||||
$reservationDate = now()->addDays(7)->format('Y-m-d H:i');
|
||||
|
||||
return new $mailClass(
|
||||
$receiver->name,
|
||||
$postTitle,
|
||||
$postOwner,
|
||||
$postUrl,
|
||||
$reservationDate
|
||||
);
|
||||
}
|
||||
|
||||
protected function buildReactionMail($receiver)
|
||||
{
|
||||
return new ReactionCreatedMail(
|
||||
$receiver->name,
|
||||
'Test Commenter',
|
||||
'Test Post Title',
|
||||
'This is a test comment for email testing purposes.',
|
||||
url('/posts/test-post')
|
||||
);
|
||||
}
|
||||
|
||||
protected function buildTagAddedMail($receiver)
|
||||
{
|
||||
return new TagAddedMail(
|
||||
$receiver->name,
|
||||
'Test Tag',
|
||||
'Test Admin',
|
||||
url('/profile/' . $receiver->name)
|
||||
);
|
||||
}
|
||||
|
||||
protected function getTestCall($receiver): Call
|
||||
{
|
||||
return Call::where('callable_type', get_class($receiver))
|
||||
->where('callable_id', $receiver->id)
|
||||
->with(['tag'])
|
||||
->first()
|
||||
?? Call::with(['tag'])->first()
|
||||
?? throw new \Exception('No calls found for test');
|
||||
}
|
||||
|
||||
protected function buildCallExpiredMail($receiver, $receiverType): CallExpiredMail
|
||||
{
|
||||
return new CallExpiredMail($this->getTestCall($receiver), $receiver, ucfirst($receiverType));
|
||||
}
|
||||
|
||||
protected function buildCallExpiringMail($receiver, $receiverType): CallExpiringMail
|
||||
{
|
||||
return new CallExpiringMail($this->getTestCall($receiver), $receiver, ucfirst($receiverType), 7);
|
||||
}
|
||||
|
||||
protected function buildCallBlockedMail($receiver, $receiverType): CallBlockedMail
|
||||
{
|
||||
return new CallBlockedMail($this->getTestCall($receiver), $receiver, ucfirst($receiverType));
|
||||
}
|
||||
|
||||
protected function getLinkedProfileForTest($receiver)
|
||||
{
|
||||
// Find a different profile type to use as the linked profile
|
||||
// If receiver is a User, find an Organization/Admin/Bank to link
|
||||
// If receiver is Organization/Admin/Bank, find a User to link
|
||||
|
||||
if ($receiver instanceof User) {
|
||||
// Try to find an organization first, then admin, then bank
|
||||
$linked = Organization::first() ?? Admin::first() ?? Bank::first();
|
||||
} else {
|
||||
// For Organization/Admin/Bank, find a user
|
||||
$linked = User::first();
|
||||
}
|
||||
|
||||
// If we can't find any other profile, just return the same receiver
|
||||
return $linked ?? $receiver;
|
||||
}
|
||||
}
|
||||
47
app/Console/Commands/SyncLocationDataCommand.php
Normal file
47
app/Console/Commands/SyncLocationDataCommand.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Locations\Location;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncLocationDataCommand extends Command
|
||||
{
|
||||
protected $signature = 'locations:sync-hierarchy {--force : Force sync even if data exists}';
|
||||
protected $description = 'Sync missing location hierarchy data (i.e. divisions, countries, from cities).';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$query = Location::query();
|
||||
|
||||
if (!$this->option('force')) {
|
||||
// Only sync locations that are missing division data
|
||||
$query->whereNotNull('city_id')->whereNull('division_id');
|
||||
}
|
||||
|
||||
$locations = $query->get();
|
||||
$syncedCount = 0;
|
||||
$totalSynced = [];
|
||||
|
||||
$this->info("Processing {$locations->count()} locations...");
|
||||
|
||||
foreach ($locations as $location) {
|
||||
try {
|
||||
$synced = $location->syncAllLocationData();
|
||||
if (!empty($synced)) {
|
||||
$syncedCount++;
|
||||
$totalSynced = array_merge($totalSynced, $synced);
|
||||
$this->line("Location ID {$location->id}: " . implode(', ', $synced));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Failed to sync location ID {$location->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$syncStats = array_count_values($totalSynced);
|
||||
$this->info("\nCompleted syncing {$syncedCount} locations:");
|
||||
foreach ($syncStats as $type => $count) {
|
||||
$this->info(" - {$count} locations synced {$type}");
|
||||
}
|
||||
}
|
||||
}
|
||||
227
app/Console/Commands/TestBounceSystem.php
Normal file
227
app/Console/Commands/TestBounceSystem.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MailingBounce;
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TestBounceSystem extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'test:bounce-system
|
||||
{--email= : Email to test (default: creates test emails)}
|
||||
{--scenario= : Test scenario: single, threshold-verification, threshold-suppression, multiple}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Test the bounce handling system with simulated bounces';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$scenario = $this->option('scenario') ?: 'single';
|
||||
$email = $this->option('email');
|
||||
|
||||
switch ($scenario) {
|
||||
case 'single':
|
||||
$this->testSingleBounce($email);
|
||||
break;
|
||||
case 'threshold-verification':
|
||||
$this->testVerificationThreshold($email);
|
||||
break;
|
||||
case 'threshold-suppression':
|
||||
$this->testSuppressionThreshold($email);
|
||||
break;
|
||||
case 'multiple':
|
||||
$this->testMultipleEmails();
|
||||
break;
|
||||
default:
|
||||
$this->error("Unknown scenario: {$scenario}");
|
||||
$this->info("Available scenarios: single, threshold-verification, threshold-suppression, multiple");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a single bounce (should not trigger any thresholds)
|
||||
*/
|
||||
protected function testSingleBounce(?string $email): void
|
||||
{
|
||||
$testEmail = $email ?: 'test-single@example.com';
|
||||
|
||||
$this->info("🧪 Testing Single Bounce for: {$testEmail}");
|
||||
|
||||
// Create a test user with verified email
|
||||
$this->createTestUser($testEmail);
|
||||
|
||||
// Record a single hard bounce with definitive pattern
|
||||
$bounce = MailingBounce::recordBounce(
|
||||
$testEmail,
|
||||
'hard',
|
||||
'user unknown - mailbox does not exist'
|
||||
);
|
||||
|
||||
$this->line("✅ Created bounce record ID: {$bounce->id}");
|
||||
|
||||
// Check the results
|
||||
$stats = MailingBounce::getBounceStats($testEmail);
|
||||
$this->displayResults($testEmail, $stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test verification threshold (2 bounces)
|
||||
*/
|
||||
protected function testVerificationThreshold(?string $email): void
|
||||
{
|
||||
$testEmail = $email ?: 'test-verification@example.com';
|
||||
|
||||
$this->info("🧪 Testing Verification Reset Threshold for: {$testEmail}");
|
||||
|
||||
// Create test user with verified email
|
||||
$user = $this->createTestUser($testEmail);
|
||||
$this->line("📧 Created test user with verified email: {$user->email_verified_at}");
|
||||
|
||||
// Record first bounce with exact pattern from config
|
||||
$this->line("1️⃣ Recording first hard bounce...");
|
||||
MailingBounce::recordBounce($testEmail, 'hard', 'user unknown - definitive bounce');
|
||||
|
||||
$user->refresh();
|
||||
$this->line(" User email_verified_at: " . ($user->email_verified_at ?: 'NULL'));
|
||||
|
||||
// Record second bounce (should trigger verification reset)
|
||||
$this->line("2️⃣ Recording second hard bounce (should reset verification)...");
|
||||
MailingBounce::recordBounce($testEmail, 'hard', 'mailbox unavailable - permanent failure');
|
||||
|
||||
$user->refresh();
|
||||
$this->line(" User email_verified_at: " . ($user->email_verified_at ?: 'NULL'));
|
||||
|
||||
// Check results
|
||||
$stats = MailingBounce::getBounceStats($testEmail);
|
||||
$this->displayResults($testEmail, $stats);
|
||||
|
||||
if (!$user->email_verified_at) {
|
||||
$this->info("✅ SUCCESS: Email verification was reset!");
|
||||
} else {
|
||||
$this->error("❌ FAILED: Email verification was NOT reset!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test suppression threshold (3 bounces)
|
||||
*/
|
||||
protected function testSuppressionThreshold(?string $email): void
|
||||
{
|
||||
$testEmail = $email ?: 'test-suppression@example.com';
|
||||
|
||||
$this->info("🧪 Testing Suppression Threshold for: {$testEmail}");
|
||||
|
||||
// Create test user
|
||||
$user = $this->createTestUser($testEmail);
|
||||
|
||||
// Record three bounces
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$this->line("{$i}️⃣ Recording hard bounce #{$i}...");
|
||||
MailingBounce::recordBounce($testEmail, 'hard', "user unknown - attempt {$i}");
|
||||
|
||||
$user->refresh();
|
||||
$isSuppressed = MailingBounce::isSuppressed($testEmail);
|
||||
|
||||
$this->line(" Suppressed: " . ($isSuppressed ? 'YES' : 'NO'));
|
||||
$this->line(" Email verified: " . ($user->email_verified_at ? 'YES' : 'NO'));
|
||||
}
|
||||
|
||||
// Check final results
|
||||
$stats = MailingBounce::getBounceStats($testEmail);
|
||||
$this->displayResults($testEmail, $stats);
|
||||
|
||||
if ($stats['is_suppressed']) {
|
||||
$this->info("✅ SUCCESS: Email was suppressed after 3 bounces!");
|
||||
} else {
|
||||
$this->error("❌ FAILED: Email was NOT suppressed!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test multiple emails with different scenarios
|
||||
*/
|
||||
protected function testMultipleEmails(): void
|
||||
{
|
||||
$this->info("🧪 Testing Multiple Email Scenarios");
|
||||
|
||||
$scenarios = [
|
||||
'no-bounce@example.com' => 0,
|
||||
'one-bounce@example.com' => 1,
|
||||
'verification-reset@example.com' => 2,
|
||||
'suppressed@example.com' => 3,
|
||||
'over-threshold@example.com' => 5
|
||||
];
|
||||
|
||||
foreach ($scenarios as $email => $bounceCount) {
|
||||
$this->line("Setting up {$email} with {$bounceCount} bounces...");
|
||||
|
||||
$this->createTestUser($email);
|
||||
|
||||
for ($i = 1; $i <= $bounceCount; $i++) {
|
||||
MailingBounce::recordBounce($email, 'hard', "user unknown - bounce {$i}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("\n📊 Results Summary:");
|
||||
foreach ($scenarios as $email => $expectedBounces) {
|
||||
$stats = MailingBounce::getBounceStats($email);
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
$this->line("📧 {$email}:");
|
||||
$this->line(" Hard bounces: {$stats['recent_hard_bounces']}");
|
||||
$this->line(" Suppressed: " . ($stats['is_suppressed'] ? 'YES' : 'NO'));
|
||||
$this->line(" Verified: " . ($user && $user->email_verified_at ? 'YES' : 'NO'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test user with verified email
|
||||
*/
|
||||
protected function createTestUser(string $email): User
|
||||
{
|
||||
// Remove existing test user if any
|
||||
User::where('email', $email)->delete();
|
||||
|
||||
$user = User::create([
|
||||
'name' => 'Test User ' . substr($email, 0, strpos($email, '@')),
|
||||
'email' => $email,
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
|
||||
// Use forceFill since email_verified_at isn't in fillable
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display test results
|
||||
*/
|
||||
protected function displayResults(string $email, array $stats): void
|
||||
{
|
||||
$this->info("\n📊 Test Results for {$email}:");
|
||||
$this->line("Total bounces: {$stats['total_bounces']}");
|
||||
$this->line("Recent hard bounces: {$stats['recent_hard_bounces']}");
|
||||
$this->line("Is suppressed: " . ($stats['is_suppressed'] ? 'YES' : 'NO'));
|
||||
|
||||
$user = User::where('email', $email)->first();
|
||||
if ($user) {
|
||||
$this->line("Email verified: " . ($user->email_verified_at ? 'YES' : 'NO'));
|
||||
}
|
||||
|
||||
$this->line("");
|
||||
}
|
||||
}
|
||||
132
app/Console/Commands/TestMailpitIntegration.php
Normal file
132
app/Console/Commands/TestMailpitIntegration.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Mailing;
|
||||
use App\Models\MailingBounce;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class TestMailpitIntegration extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'test:mailpit-integration
|
||||
{--send-test : Send a test email via Mailpit}
|
||||
{--test-suppression : Test that suppressed emails are blocked}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Test Mailpit integration and bounce suppression';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
if ($this->option('send-test')) {
|
||||
$this->sendTestEmail();
|
||||
}
|
||||
|
||||
if ($this->option('test-suppression')) {
|
||||
$this->testSuppressionInSending();
|
||||
}
|
||||
|
||||
if (!$this->option('send-test') && !$this->option('test-suppression')) {
|
||||
$this->info('Available options:');
|
||||
$this->line(' --send-test Send a test email via Mailpit');
|
||||
$this->line(' --test-suppression Test that suppressed emails are blocked');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test email via Mailpit
|
||||
*/
|
||||
protected function sendTestEmail(): void
|
||||
{
|
||||
$this->info('🧪 Testing Email Sending via Mailpit');
|
||||
|
||||
// Create a test user
|
||||
$testUser = User::where('email', 'mailpit-test@example.com')->first();
|
||||
if (!$testUser) {
|
||||
$testUser = User::create([
|
||||
'name' => 'Mailpit Test User',
|
||||
'email' => 'mailpit-test@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
$testUser->forceFill(['email_verified_at' => now()])->save();
|
||||
}
|
||||
|
||||
// Send a simple test email
|
||||
try {
|
||||
Mail::raw('This is a test email from the bounce handling system!', function ($message) use ($testUser) {
|
||||
$message->to($testUser->email)
|
||||
->subject('Bounce System Test Email')
|
||||
->from('test@timebank.cc', 'Timebank Test');
|
||||
});
|
||||
|
||||
$this->info("✅ Test email sent to: {$testUser->email}");
|
||||
$this->line("📧 Check Mailpit at: http://localhost:8025");
|
||||
$this->line("💡 The email should appear in your Mailpit inbox");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error("❌ Failed to send email: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that suppressed emails are blocked from sending
|
||||
*/
|
||||
protected function testSuppressionInSending(): void
|
||||
{
|
||||
$this->info('🧪 Testing Suppression During Email Sending');
|
||||
|
||||
// Use one of our test suppressed emails
|
||||
$suppressedEmail = 'suppressed@example.com';
|
||||
|
||||
// Verify it's actually suppressed
|
||||
$isSuppressed = MailingBounce::isSuppressed($suppressedEmail);
|
||||
$this->line("Email {$suppressedEmail} suppressed: " . ($isSuppressed ? 'YES' : 'NO'));
|
||||
|
||||
if (!$isSuppressed) {
|
||||
$this->warn("Email is not suppressed. Run the bounce tests first:");
|
||||
$this->line("php artisan test:bounce-system --scenario=multiple");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the user
|
||||
$user = User::where('email', $suppressedEmail)->first();
|
||||
if (!$user) {
|
||||
$this->warn("User not found. Run the bounce tests first.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to send an email (this should be blocked)
|
||||
$this->line("Attempting to send email to suppressed address...");
|
||||
|
||||
try {
|
||||
// This is how the actual mailing system would check
|
||||
if (MailingBounce::isSuppressed($user->email)) {
|
||||
$this->info("✅ SUCCESS: Email sending was blocked for suppressed address");
|
||||
$this->line(" This is the expected behavior - suppressed emails are not sent");
|
||||
} else {
|
||||
$this->error("❌ FAILED: Suppressed email was not blocked");
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error("❌ Error during suppression test: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Show bounce stats for this email
|
||||
$stats = MailingBounce::getBounceStats($suppressedEmail);
|
||||
$this->line("\nBounce Statistics for {$suppressedEmail}:");
|
||||
$this->line(" Total bounces: {$stats['total_bounces']}");
|
||||
$this->line(" Recent hard bounces: {$stats['recent_hard_bounces']}");
|
||||
$this->line(" Is suppressed: " . ($stats['is_suppressed'] ? 'YES' : 'NO'));
|
||||
}
|
||||
}
|
||||
188
app/Console/Commands/TestUniversalBounceSystem.php
Normal file
188
app/Console/Commands/TestUniversalBounceSystem.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MailingBounce;
|
||||
use App\Models\User;
|
||||
use App\Mail\TransferReceived;
|
||||
use App\Mail\NewMessageMail;
|
||||
use App\Mail\ContactFormMailable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TestUniversalBounceSystem extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'test:universal-bounce
|
||||
{--email= : Email to test (default: creates test emails)}
|
||||
{--scenario= : Test scenario: suppressed, normal, mixed}
|
||||
{--mailable= : Test specific mailable: transfer, contact, all}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Test universal bounce handling system with different mailable types';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$scenario = $this->option('scenario') ?: 'suppressed';
|
||||
$email = $this->option('email');
|
||||
$mailable = $this->option('mailable') ?: 'all';
|
||||
|
||||
$this->info('🧪 Testing Universal Bounce Handling System');
|
||||
|
||||
switch ($scenario) {
|
||||
case 'suppressed':
|
||||
$this->testSuppressedEmail($email, $mailable);
|
||||
break;
|
||||
case 'normal':
|
||||
$this->testNormalEmail($email, $mailable);
|
||||
break;
|
||||
case 'mixed':
|
||||
$this->testMixedRecipients($mailable);
|
||||
break;
|
||||
default:
|
||||
$this->error("Unknown scenario: {$scenario}");
|
||||
$this->info("Available scenarios: suppressed, normal, mixed");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sending to suppressed email (should be blocked)
|
||||
*/
|
||||
protected function testSuppressedEmail(?string $email, string $mailable): void
|
||||
{
|
||||
$testEmail = $email ?: 'test-suppressed@example.com';
|
||||
|
||||
$this->info("📧 Testing suppressed email: {$testEmail}");
|
||||
|
||||
// Ensure the email is suppressed
|
||||
MailingBounce::suppressEmail($testEmail, 'Test suppression for universal bounce system');
|
||||
|
||||
$this->line("✅ Email {$testEmail} is now suppressed");
|
||||
|
||||
// Test different mailable types
|
||||
if ($mailable === 'all' || $mailable === 'contact') {
|
||||
$this->testContactFormMailable($testEmail);
|
||||
}
|
||||
|
||||
if ($mailable === 'all' || $mailable === 'transfer') {
|
||||
$this->testTransferMailable($testEmail);
|
||||
}
|
||||
|
||||
$this->info("📊 Check your logs to see if suppressed emails were blocked");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sending to normal email (should work)
|
||||
*/
|
||||
protected function testNormalEmail(?string $email, string $mailable): void
|
||||
{
|
||||
$testEmail = $email ?: 'test-normal@example.com';
|
||||
|
||||
$this->info("📧 Testing normal email: {$testEmail}");
|
||||
|
||||
// Ensure the email is not suppressed
|
||||
MailingBounce::where('email', $testEmail)->delete();
|
||||
|
||||
$this->line("✅ Email {$testEmail} is not suppressed");
|
||||
|
||||
// Test different mailable types
|
||||
if ($mailable === 'all' || $mailable === 'contact') {
|
||||
$this->testContactFormMailable($testEmail);
|
||||
}
|
||||
|
||||
if ($mailable === 'all' || $mailable === 'transfer') {
|
||||
$this->testTransferMailable($testEmail);
|
||||
}
|
||||
|
||||
$this->info("📊 Check Mailpit at http://localhost:8025 to see sent emails");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test mixed recipients (some suppressed, some normal)
|
||||
*/
|
||||
protected function testMixedRecipients(string $mailable): void
|
||||
{
|
||||
$this->info("📧 Testing mixed recipients (some suppressed, some normal)");
|
||||
|
||||
// Set up test data
|
||||
$suppressedEmail = 'suppressed-mixed@example.com';
|
||||
$normalEmail = 'normal-mixed@example.com';
|
||||
|
||||
MailingBounce::suppressEmail($suppressedEmail, 'Test mixed recipients');
|
||||
MailingBounce::where('email', $normalEmail)->delete();
|
||||
|
||||
$this->line("✅ Set up mixed recipient scenario");
|
||||
|
||||
// Note: This test would require modifying existing mailables to support multiple recipients
|
||||
// or creating a special test mailable. For now, we'll test individually.
|
||||
|
||||
$this->line("Testing suppressed email in mixed scenario...");
|
||||
$this->testContactFormMailable($suppressedEmail);
|
||||
|
||||
$this->line("Testing normal email in mixed scenario...");
|
||||
$this->testContactFormMailable($normalEmail);
|
||||
|
||||
$this->info("📊 Check logs and Mailpit to verify behavior");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test ContactFormMailable
|
||||
*/
|
||||
protected function testContactFormMailable(string $email): void
|
||||
{
|
||||
$this->line(" Testing ContactFormMailable to: {$email}");
|
||||
|
||||
$contactData = [
|
||||
'email' => $email,
|
||||
'name' => 'Test User',
|
||||
'message' => 'Universal bounce test message'
|
||||
];
|
||||
|
||||
try {
|
||||
// This will use the universal bounce handler via the MessageSending event
|
||||
Mail::to($email)->send(new ContactFormMailable($contactData));
|
||||
$this->line(" ✅ ContactFormMailable sent (or blocked by bounce handler)");
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ❌ Error sending ContactFormMailable: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test TransferMailable (requires a user)
|
||||
*/
|
||||
protected function testTransferMailable(string $email): void
|
||||
{
|
||||
$this->line(" Testing TransferReceived to: {$email}");
|
||||
|
||||
// Create or find a test user
|
||||
$user = User::where('email', $email)->first();
|
||||
if (!$user) {
|
||||
$user = User::create([
|
||||
'name' => 'Bounce Test User',
|
||||
'email' => $email,
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Create a mock transaction (this is simplified for testing)
|
||||
try {
|
||||
// Note: TransferReceived requires a Transaction model which has complex relationships
|
||||
// For testing purposes, we'll just try to send a simple contact form instead
|
||||
$this->line(" (Skipping TransferReceived test - requires full transaction setup)");
|
||||
$this->testContactFormMailable($email);
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ❌ Error with transfer test: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Console/Commands/Test_ChangeUserLang.php
Normal file
51
app/Console/Commands/Test_ChangeUserLang.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\Test_UserLangChangedEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class Test_ChangeUserLang extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'user:lang';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Change user language with a public channel';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$user = User::find(2);
|
||||
|
||||
$lang = [
|
||||
'nl',
|
||||
'en',
|
||||
'fr',
|
||||
'es',
|
||||
'de',
|
||||
'it',
|
||||
];
|
||||
$user->update([
|
||||
'locale' => Arr::random($lang),
|
||||
]);
|
||||
|
||||
Test_UserLangChangedEvent::dispatch($user);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
125
app/Console/Commands/TrimInactiveProfileLogs.php
Normal file
125
app/Console/Commands/TrimInactiveProfileLogs.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class TrimInactiveProfileLogs extends Command
|
||||
{
|
||||
protected $signature = 'profiles:trim-logs {--days=30 : Number of days of logs to keep}';
|
||||
|
||||
protected $description = 'Trim inactive profile log files to retain only recent entries';
|
||||
|
||||
protected $logFiles = [
|
||||
'inactive-profiles.log',
|
||||
'mark-inactive-profiles.log',
|
||||
];
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$daysToKeep = (int) $this->option('days');
|
||||
$cutoffDate = Carbon::now()->subDays($daysToKeep);
|
||||
|
||||
$this->info("Trimming log files older than {$daysToKeep} days ({$cutoffDate->format('Y-m-d H:i:s')})...");
|
||||
|
||||
$totalSizeBefore = 0;
|
||||
$totalSizeAfter = 0;
|
||||
$filesProcessed = 0;
|
||||
|
||||
foreach ($this->logFiles as $logFileName) {
|
||||
$logPath = storage_path('logs/' . $logFileName);
|
||||
|
||||
if (!File::exists($logPath)) {
|
||||
$this->warn("Log file not found: {$logFileName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$sizeBefore = File::size($logPath);
|
||||
$totalSizeBefore += $sizeBefore;
|
||||
|
||||
$trimmed = $this->trimLogFile($logPath, $cutoffDate);
|
||||
|
||||
$sizeAfter = File::size($logPath);
|
||||
$totalSizeAfter += $sizeAfter;
|
||||
$filesProcessed++;
|
||||
|
||||
if ($trimmed) {
|
||||
$savedBytes = $sizeBefore - $sizeAfter;
|
||||
$savedKB = round($savedBytes / 1024, 2);
|
||||
$this->info("✓ {$logFileName}: Trimmed from " . $this->formatFileSize($sizeBefore) . " to " . $this->formatFileSize($sizeAfter) . " (saved {$savedKB} KB)");
|
||||
} else {
|
||||
$this->info("✓ {$logFileName}: No entries older than {$daysToKeep} days (size: " . $this->formatFileSize($sizeBefore) . ")");
|
||||
}
|
||||
}
|
||||
|
||||
if ($filesProcessed > 0) {
|
||||
$totalSaved = $totalSizeBefore - $totalSizeAfter;
|
||||
$this->info("\nTotal: Processed {$filesProcessed} files, saved " . $this->formatFileSize($totalSaved));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim log file to keep only entries newer than cutoff date.
|
||||
*
|
||||
* @param string $logPath
|
||||
* @param Carbon $cutoffDate
|
||||
* @return bool Whether any entries were removed
|
||||
*/
|
||||
protected function trimLogFile($logPath, $cutoffDate)
|
||||
{
|
||||
$content = File::get($logPath);
|
||||
$lines = explode("\n", $content);
|
||||
$keptLines = [];
|
||||
$removedCount = 0;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Skip empty lines
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract timestamp from log line format: [YYYY-MM-DD HH:MM:SS] message
|
||||
if (preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/', $line, $matches)) {
|
||||
$logDate = Carbon::parse($matches[1]);
|
||||
|
||||
if ($logDate->greaterThanOrEqualTo($cutoffDate)) {
|
||||
$keptLines[] = $line;
|
||||
} else {
|
||||
$removedCount++;
|
||||
}
|
||||
} else {
|
||||
// Keep lines without timestamps (shouldn't happen, but be safe)
|
||||
$keptLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
if ($removedCount > 0) {
|
||||
// Rewrite the log file with only kept lines
|
||||
File::put($logPath, implode("\n", $keptLines) . "\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in human-readable format.
|
||||
*
|
||||
* @param int $bytes
|
||||
* @return string
|
||||
*/
|
||||
protected function formatFileSize($bytes)
|
||||
{
|
||||
if ($bytes >= 1048576) {
|
||||
return round($bytes / 1048576, 2) . ' MB';
|
||||
} elseif ($bytes >= 1024) {
|
||||
return round($bytes / 1024, 2) . ' KB';
|
||||
} else {
|
||||
return $bytes . ' bytes';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Namu\WireChat\Models\Conversation;
|
||||
|
||||
class UpdateExistingConversationsDisappearing extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'wirechat:update-conversations-disappearing';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Set disappearing_started_at for existing conversations without this field';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
if (!timebank_config('wirechat.disappearing_messages.enabled', true)) {
|
||||
$this->info('Disappearing messages feature is disabled');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Updating existing conversations...');
|
||||
|
||||
// Find all conversations without disappearing_started_at or disappearing_duration set
|
||||
$conversations = Conversation::where(function($query) {
|
||||
$query->whereNull('disappearing_started_at')
|
||||
->orWhereNull('disappearing_duration');
|
||||
})->get();
|
||||
|
||||
if ($conversations->isEmpty()) {
|
||||
$this->info('No conversations need updating');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Get duration in days from config and convert to seconds
|
||||
$durationInDays = timebank_config('wirechat.disappearing_messages.duration', 30);
|
||||
$duration = $durationInDays * 86400; // Convert days to seconds
|
||||
|
||||
$count = 0;
|
||||
foreach ($conversations as $conversation) {
|
||||
$conversation->disappearing_started_at = now();
|
||||
$conversation->disappearing_duration = $duration;
|
||||
$conversation->save();
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->info("Updated {$count} conversations with duration {$duration} seconds");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
501
app/Console/Commands/ValidateTagTranslations.php
Normal file
501
app/Console/Commands/ValidateTagTranslations.php
Normal file
@@ -0,0 +1,501 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ValidateTagTranslations extends Command
|
||||
{
|
||||
protected $signature = 'tags:validate-translations
|
||||
{--locale= : Check specific locale only}
|
||||
{--show-missing : Show contexts missing translations in specific locales}
|
||||
{--show-duplicates : Show duplicate tag names within same locale}
|
||||
{--show-contexts : Show context distribution}';
|
||||
|
||||
// Example to show missing context (tags that are not translated in all languages)
|
||||
// php artisan tags:validate-translations --locale=nl --show-missing
|
||||
|
||||
// Example to show and remove duplicate tags
|
||||
// Note that only if you include the locale flag you will be asked to remove any duplicates
|
||||
// php artisan tags:validate-translations --locale=de --show-duplicates
|
||||
|
||||
protected $description = 'Validate tag translations across all supported locales';
|
||||
|
||||
protected array $supportedLocales = ['en', 'nl', 'fr', 'es', 'de'];
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$specificLocale = $this->option('locale');
|
||||
$showMissing = $this->option('show-missing');
|
||||
$showDuplicates = $this->option('show-duplicates');
|
||||
$showContexts = $this->option('show-contexts');
|
||||
|
||||
$locales = $specificLocale ? [$specificLocale] : $this->supportedLocales;
|
||||
|
||||
$this->info('Validating tag structure and translations...');
|
||||
|
||||
// Get total tags and contexts
|
||||
$totalTags = DB::table('taggable_tags')->count();
|
||||
$totalContexts = DB::table('taggable_contexts')->count();
|
||||
|
||||
$this->info("Total tags: {$totalTags}");
|
||||
$this->info("Total contexts: {$totalContexts}");
|
||||
|
||||
// Show tag distribution by locale
|
||||
$results = [];
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
$tagsInLocale = DB::table('taggable_tags')
|
||||
->join('taggable_locales', 'taggable_tags.tag_id', '=', 'taggable_locales.taggable_tag_id')
|
||||
->where('taggable_locales.locale', $locale)
|
||||
->count();
|
||||
|
||||
// Count contexts that have tags in this locale
|
||||
$contextsWithLocale = DB::table('taggable_contexts')
|
||||
->join('taggable_locale_context', 'taggable_contexts.id', '=', 'taggable_locale_context.context_id')
|
||||
->join('taggable_locales', 'taggable_locale_context.tag_id', '=', 'taggable_locales.taggable_tag_id')
|
||||
->where('taggable_locales.locale', $locale)
|
||||
->distinct('taggable_contexts.id')
|
||||
->count();
|
||||
|
||||
$missingContexts = $totalContexts - $contextsWithLocale;
|
||||
$percentage = $totalContexts > 0 ? round(($contextsWithLocale / $totalContexts) * 100, 1) : 0;
|
||||
|
||||
$results[] = [
|
||||
'locale' => $locale,
|
||||
'tags' => $tagsInLocale,
|
||||
'contexts_covered' => $contextsWithLocale,
|
||||
'contexts_missing' => $missingContexts,
|
||||
'coverage' => $percentage . '%'
|
||||
];
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->table(['Locale', 'Tags', 'Contexts Covered', 'Missing Contexts', 'Coverage'], $results);
|
||||
|
||||
if ($showMissing && $specificLocale) {
|
||||
$this->showMissingContextsForLocale($specificLocale);
|
||||
}
|
||||
|
||||
if ($showDuplicates && $specificLocale) {
|
||||
$this->showDuplicateTagsInLocale($specificLocale);
|
||||
}
|
||||
|
||||
if ($showContexts) {
|
||||
$this->showContextDistribution();
|
||||
}
|
||||
|
||||
// Check for orphaned data
|
||||
$this->checkOrphanedData();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Validation complete!');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show contexts that are missing tags in a specific locale
|
||||
*/
|
||||
protected function showMissingContextsForLocale(string $locale): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info("Analyzing missing contexts for locale: {$locale}");
|
||||
|
||||
// Get all context IDs that DO have tags in the specified locale
|
||||
$contextsWithLocale = DB::table('taggable_locale_context as tlc')
|
||||
->join('taggable_locales as tl', 'tlc.tag_id', '=', 'tl.taggable_tag_id')
|
||||
->where('tl.locale', $locale)
|
||||
->distinct()
|
||||
->pluck('tlc.context_id');
|
||||
|
||||
// Get contexts that DON'T have tags in the specified locale
|
||||
$missingContexts = DB::table('taggable_contexts as tc')
|
||||
->whereNotIn('tc.id', $contextsWithLocale)
|
||||
->join('categories as c', 'tc.category_id', '=', 'c.id')
|
||||
->join('category_translations as ct', function ($join) {
|
||||
$join->on('c.id', '=', 'ct.category_id')
|
||||
->where('ct.locale', '=', 'en');
|
||||
})
|
||||
->select(
|
||||
'tc.id as context_id',
|
||||
'tc.category_id',
|
||||
'ct.name as category_name',
|
||||
'ct.slug as category_slug'
|
||||
)
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
if ($missingContexts->isEmpty()) {
|
||||
$this->info("✓ All contexts have tags in {$locale}!");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->warn("Found " . $missingContexts->count() . " contexts missing {$locale} tags:");
|
||||
$this->newLine();
|
||||
|
||||
foreach ($missingContexts as $context) {
|
||||
$this->line("📂 <fg=yellow>Context {$context->context_id}</fg=yellow>: {$context->category_name}");
|
||||
|
||||
// Show what tags exist in other languages for this context
|
||||
$existingTags = DB::table('taggable_locale_context as tlc')
|
||||
->join('taggable_tags as tt', 'tlc.tag_id', '=', 'tt.tag_id')
|
||||
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
|
||||
->where('tlc.context_id', $context->context_id)
|
||||
->select('tt.tag_id', 'tt.name', 'tl.locale')
|
||||
->orderBy('tl.locale')
|
||||
->get()
|
||||
->groupBy('locale');
|
||||
|
||||
if ($existingTags->isNotEmpty()) {
|
||||
$this->line(" <fg=green>Existing tags in other languages:</fg=green>");
|
||||
foreach ($existingTags as $existingLocale => $tags) {
|
||||
$tagNames = $tags->pluck('name')->take(3)->implode(', ');
|
||||
$moreCount = $tags->count() > 3 ? ' (+' . ($tags->count() - 3) . ' more)' : '';
|
||||
$this->line(" • {$existingLocale}: {$tagNames}{$moreCount}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(" <fg=cyan>💡 Action needed:</fg=cyan> Create {$locale} tags for this context");
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// Provide actionable summary
|
||||
$this->info("🔧 How to fix missing contexts:");
|
||||
$this->line("1. Create {$locale} tags that represent the same skills/concepts");
|
||||
$this->line("2. Link them to the appropriate context using the JSON import:");
|
||||
$this->newLine();
|
||||
|
||||
// Generate example JSON structure
|
||||
$this->line("<fg=green>Example JSON structure to create missing {$locale} tags:</fg=green>");
|
||||
$this->line('{');
|
||||
$this->line(' "tags": [');
|
||||
|
||||
$exampleContext = $missingContexts->first();
|
||||
if ($exampleContext) {
|
||||
// Get an example tag from another language for this context
|
||||
$exampleTag = DB::table('taggable_locale_context as tlc')
|
||||
->join('taggable_tags as tt', 'tlc.tag_id', '=', 'tt.tag_id')
|
||||
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
|
||||
->where('tlc.context_id', $exampleContext->context_id)
|
||||
->where('tl.locale', 'en')
|
||||
->select('tt.name')
|
||||
->first();
|
||||
|
||||
$exampleName = $exampleTag ? $exampleTag->name : 'Example Skill Name';
|
||||
$translatedName = $this->getExampleTranslation($exampleName, $locale);
|
||||
|
||||
$this->line(' {');
|
||||
$this->line(' "translations": {');
|
||||
$this->line(" \"{$locale}\": \"{$translatedName}\"");
|
||||
$this->line(' },');
|
||||
$this->line(' "category": {');
|
||||
$this->line(" \"id\": {$exampleContext->context_id},");
|
||||
$this->line(" \"name\": \"{$exampleContext->category_name}\"");
|
||||
$this->line(' }');
|
||||
$this->line(' }');
|
||||
}
|
||||
|
||||
$this->line(' ]');
|
||||
$this->line('}');
|
||||
$this->newLine();
|
||||
$this->line("3. Import using: <fg=cyan>php artisan tags:import-json your-{$locale}-tags.json</fg=cyan>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get example translation for demonstration
|
||||
*/
|
||||
protected function getExampleTranslation(string $englishName, string $locale): string
|
||||
{
|
||||
$translations = [
|
||||
'nl' => [
|
||||
'Math Tutoring' => 'Wiskunde Bijles',
|
||||
'Science Help' => 'Wetenschap Hulp',
|
||||
'Language Exchange' => 'Taaluitwisseling',
|
||||
'Programming' => 'Programmeren',
|
||||
'Cooking' => 'Koken',
|
||||
'Photography' => 'Fotografie',
|
||||
],
|
||||
'fr' => [
|
||||
'Math Tutoring' => 'Cours de Mathématiques',
|
||||
'Science Help' => 'Aide Scientifique',
|
||||
'Language Exchange' => 'Échange Linguistique',
|
||||
'Programming' => 'Programmation',
|
||||
'Cooking' => 'Cuisine',
|
||||
'Photography' => 'Photographie',
|
||||
],
|
||||
'es' => [
|
||||
'Math Tutoring' => 'Clases de Matemáticas',
|
||||
'Science Help' => 'Ayuda Científica',
|
||||
'Language Exchange' => 'Intercambio de Idiomas',
|
||||
'Programming' => 'Programación',
|
||||
'Cooking' => 'Cocina',
|
||||
'Photography' => 'Fotografía',
|
||||
],
|
||||
'de' => [
|
||||
'Math Tutoring' => 'Mathe Nachhilfe',
|
||||
'Science Help' => 'Wissenschaft Hilfe',
|
||||
'Language Exchange' => 'Sprachaustausch',
|
||||
'Programming' => 'Programmierung',
|
||||
'Cooking' => 'Kochen',
|
||||
'Photography' => 'Fotografie',
|
||||
],
|
||||
];
|
||||
|
||||
return $translations[$locale][$englishName] ?? $englishName . " ({$locale})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Show duplicate tag names within the same locale
|
||||
*/
|
||||
protected function showDuplicateTagsInLocale(string $locale): void
|
||||
{
|
||||
$duplicates = DB::table('taggable_tags')
|
||||
->join('taggable_locales', 'taggable_tags.tag_id', '=', 'taggable_locales.taggable_tag_id')
|
||||
->where('taggable_locales.locale', $locale)
|
||||
->select('taggable_tags.name', DB::raw('COUNT(*) as count'), DB::raw('GROUP_CONCAT(taggable_tags.tag_id) as tag_ids'))
|
||||
->groupBy('taggable_tags.name')
|
||||
->having('count', '>', 1)
|
||||
->get();
|
||||
|
||||
if ($duplicates->isEmpty()) {
|
||||
$this->newLine();
|
||||
$this->info("✓ No duplicate tag names found in {$locale}!");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->warn("Found duplicate tag names in {$locale}:");
|
||||
|
||||
foreach ($duplicates as $duplicate) {
|
||||
$tagIds = explode(',', $duplicate->tag_ids);
|
||||
$this->line(" <fg=yellow>'{$duplicate->name}'</fg=yellow> appears {$duplicate->count} times (tag IDs: {$duplicate->tag_ids})");
|
||||
|
||||
// Show details for each duplicate tag
|
||||
foreach ($tagIds as $tagId) {
|
||||
$contexts = DB::table('taggable_locale_context as tlc')
|
||||
->join('taggable_contexts as tc', 'tlc.context_id', '=', 'tc.id')
|
||||
->join('categories as c', 'tc.category_id', '=', 'c.id')
|
||||
->join('category_translations as ct', function ($join) {
|
||||
$join->on('c.id', '=', 'ct.category_id')
|
||||
->where('ct.locale', '=', 'en');
|
||||
})
|
||||
->where('tlc.tag_id', $tagId)
|
||||
->select('tc.id as context_id', 'ct.name as category_name')
|
||||
->get();
|
||||
|
||||
$contextInfo = $contexts->isEmpty() ? 'No contexts' :
|
||||
$contexts->map(fn($c) => "Context {$c->context_id} ({$c->category_name})")->implode(', ');
|
||||
|
||||
$this->line(" Tag ID {$tagId}: {$contextInfo}");
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt to remove duplicates
|
||||
$this->newLine();
|
||||
if ($this->confirm("Do you want to remove duplicate tags for locale '{$locale}'? This will keep the first occurrence and remove others.")) {
|
||||
$this->removeDuplicateTagsForLocale($locale, $duplicates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate tags for a specific locale
|
||||
*/
|
||||
protected function removeDuplicateTagsForLocale(string $locale, $duplicates): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info("Removing duplicate tags for locale: {$locale}");
|
||||
|
||||
$totalRemoved = 0;
|
||||
|
||||
DB::transaction(function () use ($locale, $duplicates, &$totalRemoved) {
|
||||
foreach ($duplicates as $duplicate) {
|
||||
$tagIds = explode(',', $duplicate->tag_ids);
|
||||
|
||||
// Keep the first tag, remove the rest
|
||||
$keepTagId = array_shift($tagIds);
|
||||
$removeTagIds = $tagIds;
|
||||
|
||||
$this->line(" Processing '{$duplicate->name}':");
|
||||
$this->line(" Keeping tag ID: {$keepTagId}");
|
||||
$this->line(" Removing tag IDs: " . implode(', ', $removeTagIds));
|
||||
|
||||
foreach ($removeTagIds as $removeTagId) {
|
||||
// First, transfer any context associations to the kept tag
|
||||
$contexts = DB::table('taggable_locale_context')
|
||||
->where('tag_id', $removeTagId)
|
||||
->pluck('context_id');
|
||||
|
||||
foreach ($contexts as $contextId) {
|
||||
// Check if the kept tag already has this context association
|
||||
$existingAssociation = DB::table('taggable_locale_context')
|
||||
->where('tag_id', $keepTagId)
|
||||
->where('context_id', $contextId)
|
||||
->exists();
|
||||
|
||||
if (!$existingAssociation) {
|
||||
// Transfer the context association to the kept tag
|
||||
try {
|
||||
DB::table('taggable_locale_context')
|
||||
->where('tag_id', $removeTagId)
|
||||
->where('context_id', $contextId)
|
||||
->update(['tag_id' => $keepTagId]);
|
||||
|
||||
$this->line(" Transferred context {$contextId} to kept tag");
|
||||
} catch (\Illuminate\Database\UniqueConstraintViolationException $e) {
|
||||
// If update fails due to unique constraint, just delete the duplicate
|
||||
DB::table('taggable_locale_context')
|
||||
->where('tag_id', $removeTagId)
|
||||
->where('context_id', $contextId)
|
||||
->delete();
|
||||
|
||||
$this->line(" Removed duplicate context association {$contextId} (kept tag already has this context)");
|
||||
}
|
||||
} else {
|
||||
// Remove the duplicate context association
|
||||
DB::table('taggable_locale_context')
|
||||
->where('tag_id', $removeTagId)
|
||||
->where('context_id', $contextId)
|
||||
->delete();
|
||||
|
||||
$this->line(" Removed duplicate context association {$contextId} (already exists on kept tag)");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the locale record for this tag
|
||||
DB::table('taggable_locales')
|
||||
->where('taggable_tag_id', $removeTagId)
|
||||
->where('locale', $locale)
|
||||
->delete();
|
||||
|
||||
// Check if this tag has any other locale records
|
||||
$hasOtherLocales = DB::table('taggable_locales')
|
||||
->where('taggable_tag_id', $removeTagId)
|
||||
->exists();
|
||||
|
||||
// If no other locales exist, remove the tag entirely
|
||||
if (!$hasOtherLocales) {
|
||||
DB::table('taggable_tags')
|
||||
->where('tag_id', $removeTagId)
|
||||
->delete();
|
||||
|
||||
$this->line(" Removed tag {$removeTagId} entirely (no other locales)");
|
||||
} else {
|
||||
$this->line(" Removed {$locale} locale for tag {$removeTagId} (other locales exist)");
|
||||
}
|
||||
|
||||
$totalRemoved++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info("✓ Successfully removed {$totalRemoved} duplicate tags for locale '{$locale}'");
|
||||
$this->info("✓ Context associations have been preserved on the remaining tags");
|
||||
|
||||
// Run a quick verification
|
||||
$this->newLine();
|
||||
$this->info("Verifying removal...");
|
||||
$remainingDuplicates = DB::table('taggable_tags')
|
||||
->join('taggable_locales', 'taggable_tags.tag_id', '=', 'taggable_locales.taggable_tag_id')
|
||||
->where('taggable_locales.locale', $locale)
|
||||
->select('taggable_tags.name', DB::raw('COUNT(*) as count'))
|
||||
->groupBy('taggable_tags.name')
|
||||
->having('count', '>', 1)
|
||||
->count();
|
||||
|
||||
if ($remainingDuplicates === 0) {
|
||||
$this->info("✓ No duplicate tags remain in locale '{$locale}'");
|
||||
} else {
|
||||
$this->warn("⚠ {$remainingDuplicates} duplicate tag names still exist. You may need to run this again.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show distribution of tags across contexts
|
||||
*/
|
||||
protected function showContextDistribution(): void
|
||||
{
|
||||
$distribution = DB::table('taggable_contexts')
|
||||
->leftJoin('taggable_locale_context', 'taggable_contexts.id', '=', 'taggable_locale_context.context_id')
|
||||
->join('categories', 'taggable_contexts.category_id', '=', 'categories.id')
|
||||
->join('category_translations', function ($join) {
|
||||
$join->on('categories.id', '=', 'category_translations.category_id')
|
||||
->where('category_translations.locale', '=', 'en');
|
||||
})
|
||||
->select(
|
||||
'taggable_contexts.id as context_id',
|
||||
'category_translations.name as category_name',
|
||||
DB::raw('COUNT(taggable_locale_context.tag_id) as tag_count')
|
||||
)
|
||||
->groupBy('taggable_contexts.id', 'category_translations.name')
|
||||
->orderBy('tag_count', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Top 10 contexts by tag count:');
|
||||
$this->table(
|
||||
['Context ID', 'Category', 'Tag Count'],
|
||||
$distribution->map(fn($item) => [
|
||||
$item->context_id,
|
||||
$item->category_name,
|
||||
$item->tag_count
|
||||
])->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for orphaned data
|
||||
*/
|
||||
protected function checkOrphanedData(): void
|
||||
{
|
||||
// Orphaned locale records
|
||||
$orphanedLocales = DB::table('taggable_locales')
|
||||
->leftJoin('taggable_tags', 'taggable_locales.taggable_tag_id', '=', 'taggable_tags.tag_id')
|
||||
->whereNull('taggable_tags.tag_id')
|
||||
->count();
|
||||
|
||||
// Orphaned context links
|
||||
$orphanedContextLinks = DB::table('taggable_locale_context')
|
||||
->leftJoin('taggable_tags', 'taggable_locale_context.tag_id', '=', 'taggable_tags.tag_id')
|
||||
->whereNull('taggable_tags.tag_id')
|
||||
->count();
|
||||
|
||||
// Tags without locale specification
|
||||
$tagsWithoutLocale = DB::table('taggable_tags')
|
||||
->leftJoin('taggable_locales', 'taggable_tags.tag_id', '=', 'taggable_locales.taggable_tag_id')
|
||||
->whereNull('taggable_locales.id')
|
||||
->count();
|
||||
|
||||
// Tags without context
|
||||
$tagsWithoutContext = DB::table('taggable_tags')
|
||||
->leftJoin('taggable_locale_context', 'taggable_tags.tag_id', '=', 'taggable_locale_context.tag_id')
|
||||
->whereNull('taggable_locale_context.id')
|
||||
->count();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Data integrity check:');
|
||||
|
||||
if ($orphanedLocales > 0) {
|
||||
$this->warn("Orphaned locale records: {$orphanedLocales}");
|
||||
}
|
||||
|
||||
if ($orphanedContextLinks > 0) {
|
||||
$this->warn("Orphaned context links: {$orphanedContextLinks}");
|
||||
}
|
||||
|
||||
if ($tagsWithoutLocale > 0) {
|
||||
$this->warn("Tags without locale specification: {$tagsWithoutLocale}");
|
||||
}
|
||||
|
||||
if ($tagsWithoutContext > 0) {
|
||||
$this->warn("Tags without context: {$tagsWithoutContext}");
|
||||
}
|
||||
|
||||
if ($orphanedLocales === 0 && $orphanedContextLinks === 0 && $tagsWithoutLocale === 0 && $tagsWithoutContext === 0) {
|
||||
$this->info('✓ No data integrity issues found');
|
||||
}
|
||||
}
|
||||
}
|
||||
333
app/Console/Commands/VerifyCyclosMigration.php
Normal file
333
app/Console/Commands/VerifyCyclosMigration.php
Normal file
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class VerifyCyclosMigration extends Command
|
||||
{
|
||||
protected $signature = 'verify:cyclos-migration {source_db? : Name of the source Cyclos database}';
|
||||
protected $description = 'Verifies the Cyclos migration: member counts, account balances, gift accounts, and deleted profile cleanup.';
|
||||
|
||||
private int $passed = 0;
|
||||
private int $failed = 0;
|
||||
private int $warnings = 0;
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$sourceDb = $this->argument('source_db') ?? cache()->get('cyclos_migration_source_db');
|
||||
|
||||
if (empty($sourceDb)) {
|
||||
$sourceDb = $this->ask('Enter the name of the source Cyclos database');
|
||||
}
|
||||
|
||||
if (empty($sourceDb)) {
|
||||
$this->error('Source database name is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$destDb = env('DB_DATABASE');
|
||||
|
||||
$this->info("=== Cyclos Migration Verification ===");
|
||||
$this->info("Source: {$sourceDb} → Destination: {$destDb}");
|
||||
$this->newLine();
|
||||
|
||||
$this->checkMembers($sourceDb, $destDb);
|
||||
$this->newLine();
|
||||
$this->checkTransactions($sourceDb, $destDb);
|
||||
$this->newLine();
|
||||
$this->checkBalances($sourceDb, $destDb);
|
||||
$this->newLine();
|
||||
$this->checkGiftAccounts($destDb);
|
||||
$this->newLine();
|
||||
$this->checkDeletedProfiles($sourceDb, $destDb);
|
||||
$this->newLine();
|
||||
|
||||
$this->info("=== Summary ===");
|
||||
$this->info(" <fg=green>PASS</>: {$this->passed}");
|
||||
if ($this->warnings > 0) {
|
||||
$this->info(" <fg=yellow>WARN</>: {$this->warnings}");
|
||||
}
|
||||
if ($this->failed > 0) {
|
||||
$this->error(" FAIL: {$this->failed}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info('<fg=green>All checks passed!</>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. MEMBER COUNTS
|
||||
// -------------------------------------------------------------------------
|
||||
private function checkMembers(string $sourceDb, string $destDb): void
|
||||
{
|
||||
$this->info('--- 1. Member counts ---');
|
||||
|
||||
// Cyclos group_id mapping:
|
||||
// 5 = active users
|
||||
// 6 = inactive users
|
||||
// 8 = removed users
|
||||
// 13 = local banks (level I)
|
||||
// 14 = organizations
|
||||
// 15 = projects to create hours (level II banks)
|
||||
// 18 = TEST projects (organizations)
|
||||
// 22 = TEST users
|
||||
// 27 = inactive projects (organizations)
|
||||
|
||||
$cyclosActive = DB::table("{$sourceDb}.members")->where('group_id', 5)->count();
|
||||
$cyclosInactive = DB::table("{$sourceDb}.members")->where('group_id', 6)->count();
|
||||
$cyclosRemoved = DB::table("{$sourceDb}.members")->where('group_id', 8)->count();
|
||||
$cyclosBanksL1 = DB::table("{$sourceDb}.members")->where('group_id', 13)->count();
|
||||
$cyclosOrgs = DB::table("{$sourceDb}.members")->where('group_id', 14)->count();
|
||||
$cyclosBanksL2 = DB::table("{$sourceDb}.members")->where('group_id', 15)->count();
|
||||
$cyclosTestOrgs = DB::table("{$sourceDb}.members")->where('group_id', 18)->count();
|
||||
$cyclosTestUsers = DB::table("{$sourceDb}.members")->where('group_id', 22)->count();
|
||||
$cyclosInactProj = DB::table("{$sourceDb}.members")->where('group_id', 27)->count();
|
||||
|
||||
$laravelUsers = DB::table("{$destDb}.users")->whereNull('deleted_at')->whereNull('inactive_at')->whereNotNull('cyclos_id')->count();
|
||||
$laravelInactive = DB::table("{$destDb}.users")->whereNotNull('inactive_at')->count();
|
||||
$laravelRemoved = DB::table("{$destDb}.users")->whereNotNull('deleted_at')->count();
|
||||
$laravelBanks = DB::table("{$destDb}.banks")->where('id', '!=', 1)->count(); // exclude source bank
|
||||
$laravelOrgs = DB::table("{$destDb}.organizations")->whereNull('inactive_at')->count();
|
||||
$laravelInactOrgs = DB::table("{$destDb}.organizations")->whereNotNull('inactive_at')->count();
|
||||
|
||||
$expectedUsers = $cyclosActive + $cyclosTestUsers;
|
||||
$expectedOrgs = $cyclosOrgs + $cyclosTestOrgs;
|
||||
$expectedBanks = $cyclosBanksL1 + $cyclosBanksL2;
|
||||
|
||||
$this->check('Active users', $expectedUsers, $laravelUsers);
|
||||
$this->check('Inactive users', $cyclosInactive, $laravelInactive);
|
||||
$this->check('Removed/deleted users', $cyclosRemoved, $laravelRemoved);
|
||||
$this->check('Banks (L1+L2)', $expectedBanks, $laravelBanks);
|
||||
$this->check('Active organizations', $expectedOrgs, $laravelOrgs);
|
||||
$this->check('Inactive organizations', $cyclosInactProj, $laravelInactOrgs);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. TRANSACTION COUNTS
|
||||
// -------------------------------------------------------------------------
|
||||
private function checkTransactions(string $sourceDb, string $destDb): void
|
||||
{
|
||||
$this->info('--- 2. Transaction counts ---');
|
||||
|
||||
$cyclosCount = DB::table("{$sourceDb}.transfers")->count();
|
||||
$laravelTotal = DB::table("{$destDb}.transactions")->count();
|
||||
|
||||
// Post-import transactions added after Cyclos import:
|
||||
// type 5 = currency removals (for deleted profiles)
|
||||
// type 6 = gift account migrations (migrate:cyclos-gift-accounts moves gift balances to personal accounts)
|
||||
// type 7 = rounding corrections (one per account per year, inserted by migrate:cyclos)
|
||||
$giftMigCount = DB::table("{$destDb}.transactions")->where('transaction_type_id', 6)->count();
|
||||
$currRemovalCount = DB::table("{$destDb}.transactions")->where('transaction_type_id', 5)->count();
|
||||
$roundingCorrCount = DB::table("{$destDb}.transactions")->where('transaction_type_id', 7)->count();
|
||||
$laravelImported = $laravelTotal - $giftMigCount - $currRemovalCount - $roundingCorrCount;
|
||||
|
||||
$this->check('Imported transactions match Cyclos transfers', $cyclosCount, $laravelImported);
|
||||
$this->info(" (Total Laravel: {$laravelTotal} = {$laravelImported} imported + {$giftMigCount} gift migrations + {$currRemovalCount} currency removals + {$roundingCorrCount} rounding corrections)");
|
||||
|
||||
// NULL account IDs — should be zero
|
||||
$nullTx = DB::select("
|
||||
SELECT COUNT(*) as cnt FROM {$destDb}.transactions
|
||||
WHERE from_account_id IS NULL OR to_account_id IS NULL
|
||||
")[0]->cnt;
|
||||
$this->check('No transactions with NULL account IDs', 0, $nullTx);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. ACCOUNT BALANCES
|
||||
// -------------------------------------------------------------------------
|
||||
private function checkBalances(string $sourceDb, string $destDb): void
|
||||
{
|
||||
$this->info('--- 3. Account balances ---');
|
||||
|
||||
// Laravel system must be balanced (sum of all net balances = 0)
|
||||
$laravelNetBalance = DB::select("
|
||||
SELECT SUM(net) as total FROM (
|
||||
SELECT a.id,
|
||||
COALESCE(SUM(CASE WHEN t.to_account_id = a.id THEN t.amount ELSE -t.amount END), 0) as net
|
||||
FROM {$destDb}.accounts a
|
||||
LEFT JOIN {$destDb}.transactions t ON t.from_account_id = a.id OR t.to_account_id = a.id
|
||||
GROUP BY a.id
|
||||
) balances
|
||||
")[0]->total;
|
||||
|
||||
$this->check('Laravel system is balanced (net = 0)', 0, (int) $laravelNetBalance);
|
||||
|
||||
// Compare per-account balances directly via cyclos_id mapping.
|
||||
// Cyclos stores amounts in hours, Laravel in minutes.
|
||||
// Exclude post-import transactions so we compare only the imported data:
|
||||
// type 5 = currency removals (deleted profiles)
|
||||
// type 6 = gift migrations (migrate:cyclos-gift-accounts moves gift balances to personal accounts)
|
||||
// type 7 = rounding corrections (inserted by migrate:cyclos, one per account per year)
|
||||
$rows = DB::select("
|
||||
SELECT
|
||||
cyclos_type.type_id,
|
||||
ROUND(cyclos_type.cyclos_hours * 60) as cyclos_min,
|
||||
COALESCE(laravel_type.laravel_min, 0) as laravel_min
|
||||
FROM (
|
||||
SELECT a.type_id,
|
||||
COALESCE(SUM(CASE WHEN t.to_account_id = a.id THEN t.amount ELSE -t.amount END), 0) as cyclos_hours
|
||||
FROM {$sourceDb}.accounts a
|
||||
LEFT JOIN {$sourceDb}.transfers t ON t.from_account_id = a.id OR t.to_account_id = a.id
|
||||
GROUP BY a.type_id
|
||||
) cyclos_type
|
||||
LEFT JOIN (
|
||||
SELECT ca.type_id,
|
||||
COALESCE(SUM(CASE WHEN t.to_account_id = la.id THEN t.amount ELSE -t.amount END), 0) as laravel_min
|
||||
FROM {$sourceDb}.accounts ca
|
||||
INNER JOIN {$destDb}.accounts la ON ca.id = la.cyclos_id
|
||||
LEFT JOIN {$destDb}.transactions t ON (t.from_account_id = la.id OR t.to_account_id = la.id)
|
||||
AND t.transaction_type_id NOT IN (5, 6, 7)
|
||||
GROUP BY ca.type_id
|
||||
) laravel_type ON cyclos_type.type_id = laravel_type.type_id
|
||||
ORDER BY cyclos_type.type_id
|
||||
");
|
||||
|
||||
$typeNames = [
|
||||
1 => 'Debit account',
|
||||
2 => 'Community account',
|
||||
3 => 'Voucher account',
|
||||
4 => 'Organization account',
|
||||
5 => 'Work accounts (all owners)',
|
||||
6 => 'Gift accounts',
|
||||
7 => 'Project accounts',
|
||||
];
|
||||
|
||||
// Types 5 and 7 (work and project accounts) are checked combined because
|
||||
// some profiles are intentionally remapped between these types during migration.
|
||||
$combined = [5 => ['cyclos' => 0, 'laravel' => 0], 7 => ['cyclos' => 0, 'laravel' => 0]];
|
||||
foreach ($rows as $row) {
|
||||
if (in_array($row->type_id, [5, 7])) {
|
||||
$combined[$row->type_id]['cyclos'] = $row->cyclos_min;
|
||||
$combined[$row->type_id]['laravel'] = $row->laravel_min;
|
||||
continue;
|
||||
}
|
||||
$label = $typeNames[$row->type_id] ?? "Account type {$row->type_id}";
|
||||
$this->checkBalance($label, $row->cyclos_min / 60, $row->laravel_min / 60);
|
||||
}
|
||||
$combinedCyclos = ($combined[5]['cyclos'] + $combined[7]['cyclos']) / 60;
|
||||
$combinedLaravel = ($combined[5]['laravel'] + $combined[7]['laravel']) / 60;
|
||||
$this->checkBalance('Work + Project accounts combined (remappings allowed)', $combinedCyclos, $combinedLaravel);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. GIFT ACCOUNTS
|
||||
// -------------------------------------------------------------------------
|
||||
private function checkGiftAccounts(string $destDb): void
|
||||
{
|
||||
$this->info('--- 4. Gift account cleanup ---');
|
||||
|
||||
// All gift accounts should be marked inactive
|
||||
$activeGiftAccounts = DB::table("{$destDb}.accounts")
|
||||
->where('name', 'gift')
|
||||
->whereNull('inactive_at')
|
||||
->count();
|
||||
$this->check('All gift accounts marked inactive', 0, $activeGiftAccounts);
|
||||
|
||||
// All gift account net balances should be 0 (migrated away)
|
||||
$nonZeroGiftBalances = DB::select("
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM (
|
||||
SELECT a.id,
|
||||
COALESCE(SUM(CASE WHEN t.to_account_id = a.id THEN t.amount ELSE -t.amount END), 0) as net
|
||||
FROM {$destDb}.accounts a
|
||||
LEFT JOIN {$destDb}.transactions t ON t.from_account_id = a.id OR t.to_account_id = a.id
|
||||
WHERE a.name = 'gift'
|
||||
GROUP BY a.id
|
||||
HAVING ABS(net) > 0
|
||||
) nonzero
|
||||
");
|
||||
$this->check('All gift account balances are zero after migration', 0, $nonZeroGiftBalances[0]->cnt);
|
||||
|
||||
// Gift migration transactions (type 6) should exist and move from gift → personal/org
|
||||
$giftMigrations = DB::table("{$destDb}.transactions as t")
|
||||
->join("{$destDb}.accounts as fa", 't.from_account_id', '=', 'fa.id')
|
||||
->join("{$destDb}.accounts as ta", 't.to_account_id', '=', 'ta.id')
|
||||
->where('t.transaction_type_id', 6)
|
||||
->where('fa.name', 'gift')
|
||||
->whereIn('ta.name', ['personal', 'organization', 'banking system'])
|
||||
->count();
|
||||
$totalGiftMigrations = DB::table("{$destDb}.transactions")->where('transaction_type_id', 6)->count();
|
||||
$this->check('Gift migration transactions go from gift → work account', $totalGiftMigrations, $giftMigrations);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. DELETED PROFILE CLEANUP
|
||||
// -------------------------------------------------------------------------
|
||||
private function checkDeletedProfiles(string $sourceDb, string $destDb): void
|
||||
{
|
||||
$this->info('--- 5. Deleted profile cleanup ---');
|
||||
|
||||
// Removed users (group_id 8) should be soft-deleted in Laravel
|
||||
$removedCyclos = DB::table("{$sourceDb}.members")->where('group_id', 8)->count();
|
||||
$deletedLaravel = DB::table("{$destDb}.users")->whereNotNull('deleted_at')->count();
|
||||
$this->check('Removed Cyclos users are soft-deleted in Laravel', $removedCyclos, $deletedLaravel);
|
||||
|
||||
// Deleted users should have had their balances removed (currency removals, type 5).
|
||||
// Tolerance of 6 minutes to account for rounding artifacts from hours→minutes conversion.
|
||||
$deletedUsersWithBalance = DB::select("
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM (
|
||||
SELECT u.id,
|
||||
COALESCE(SUM(CASE WHEN t.to_account_id = a.id THEN t.amount ELSE -t.amount END), 0) as net
|
||||
FROM {$destDb}.users u
|
||||
INNER JOIN {$destDb}.accounts a ON a.accountable_id = u.id AND a.accountable_type = 'App\\\\Models\\\\User'
|
||||
LEFT JOIN {$destDb}.transactions t ON t.from_account_id = a.id OR t.to_account_id = a.id
|
||||
WHERE u.deleted_at IS NOT NULL
|
||||
GROUP BY u.id
|
||||
HAVING ABS(net) > 6
|
||||
) nonzero
|
||||
");
|
||||
$this->check('Deleted users have zero remaining balance (tolerance: 6min)', 0, $deletedUsersWithBalance[0]->cnt);
|
||||
|
||||
// Accounts of deleted users should exist but with zero balance
|
||||
$deletedUserAccountsCount = DB::table("{$destDb}.accounts as a")
|
||||
->join("{$destDb}.users as u", function ($join) use ($destDb) {
|
||||
$join->on('a.accountable_id', '=', 'u.id')
|
||||
->where('a.accountable_type', '=', 'App\\Models\\User');
|
||||
})
|
||||
->whereNotNull('u.deleted_at')
|
||||
->count();
|
||||
|
||||
if ($deletedUserAccountsCount > 0) {
|
||||
$this->warn(" Deleted users still have {$deletedUserAccountsCount} account records (expected — accounts are kept for transaction history)");
|
||||
$this->warnings++;
|
||||
} else {
|
||||
$this->info(" <fg=green>PASS</> No accounts found for deleted users (all cleaned up)");
|
||||
$this->passed++;
|
||||
}
|
||||
|
||||
// Currency removal transactions (type 5) are optional — only present if deleted users had balances.
|
||||
// Balance check above already confirms deleted users have zero balance, so 0 here is also valid.
|
||||
$currRemovalCount = DB::table("{$destDb}.transactions")->where('transaction_type_id', 5)->count();
|
||||
$this->info(" <fg=green>INFO</> Currency removal transactions: {$currRemovalCount}");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
private function check(string $label, $expected, $actual): void
|
||||
{
|
||||
if ($expected == $actual) {
|
||||
$this->info(" <fg=green>PASS</> {$label}: {$actual}");
|
||||
$this->passed++;
|
||||
} else {
|
||||
$this->error(" FAIL {$label}: expected={$expected} actual={$actual} (diff=" . ($actual - $expected) . ")");
|
||||
$this->failed++;
|
||||
}
|
||||
}
|
||||
|
||||
private function checkBalance(string $label, float $cyclosHours, float $laravelHours, float $toleranceHours = 0.1): void
|
||||
{
|
||||
$diff = abs($cyclosHours - $laravelHours);
|
||||
if ($diff <= $toleranceHours) {
|
||||
$this->info(sprintf(" <fg=green>PASS</> %s: %.2fh (diff: %.4fh)", $label, $laravelHours, $cyclosHours - $laravelHours));
|
||||
$this->passed++;
|
||||
} else {
|
||||
$this->error(sprintf(" FAIL %s: cyclos=%.2fh laravel=%.2fh diff=%.4fh", $label, $cyclosHours, $laravelHours, $cyclosHours - $laravelHours));
|
||||
$this->failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
app/Console/Commands/WireChatDeleteExpiredMessages.php
Normal file
45
app/Console/Commands/WireChatDeleteExpiredMessages.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\DeleteExpiredWireChatMessagesJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class WireChatDeleteExpiredMessages extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'wirechat:delete-expired';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete expired disappearing messages from WireChat conversations';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
if (!timebank_config('wirechat.disappearing_messages.enabled', true)) {
|
||||
$this->info('Disappearing messages feature is disabled');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Dispatching job to delete expired disappearing messages...');
|
||||
|
||||
// Dispatch to 'low' queue
|
||||
DeleteExpiredWireChatMessagesJob::dispatch();
|
||||
|
||||
$this->info('Job dispatched to low queue!');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user