Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View 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
)
;

View 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.");
}
}

View 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,
];
}
}

View 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)));
}
}
}
}
}

View 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>&nbsp;</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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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.");
}
}

View 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;
}
}

View 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;
}
}
}

View 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());
}
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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.");
}
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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";
}
}
}
}

View 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");
}
}

View 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");
}
}

View 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);
}
}

View 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);
}
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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));
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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}");
}
}
}

View 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("");
}
}

View 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'));
}
}

View 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());
}
}
}

View 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;
}
}

View 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';
}
}
}

View File

@@ -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;
}
}

View 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');
}
}
}

View 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++;
}
}
}

View 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;
}
}