Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -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
|
||||
)
|
||||
;
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
* Replace the legacy scoped roles Admin\{id}\admin by a role from admin_roles.
|
||||
*
|
||||
* Admin profiles used to get the role Admin\{id}\admin, with every permission of that moment and never synced
|
||||
* (admin_roles has no "admin"). The permission checks now accept any role on the profile, so these roles are
|
||||
* no longer needed. For every such role, on any installation:
|
||||
* 1. each holder gets Admin\{id}\{default role} (admin_settings.default_admin_role, 'administrator'),
|
||||
* created and synced from its definition when missing; nobody loses access;
|
||||
* 2. then the legacy role is removed.
|
||||
* Without a definition for the default role nothing changes. Running it again changes nothing.
|
||||
* Called by `php artisan database:update`; run it with --dry-run first to see what it would do.
|
||||
*/
|
||||
class AdminRolesMigrate extends Command
|
||||
{
|
||||
protected $signature = 'admin-roles:migrate {--dry-run : Show what would change, change nothing}';
|
||||
|
||||
protected $description = 'Replace the legacy Admin\\{id}\\admin roles by the default role from admin_roles';
|
||||
|
||||
/** Matches Admin\{id}\admin */
|
||||
public const LEGACY_PATTERN = '/^Admin\\\\(\d+)\\\\admin$/';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$defaultName = AdminRole::defaultName();
|
||||
$definition = AdminRole::where('name', $defaultName)->first();
|
||||
|
||||
$legacyRoles = Role::where('guard_name', 'web')->get()
|
||||
->filter(fn (Role $role) => preg_match(self::LEGACY_PATTERN, $role->name));
|
||||
|
||||
if ($legacyRoles->isEmpty()) {
|
||||
$this->info('No legacy Admin\\{id}\\admin roles: nothing to do.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$definition) {
|
||||
$this->error("The default admin role '{$defaultName}' (admin_settings.default_admin_role) has no admin_roles definition: nothing changed.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(($dryRun ? '[dry run] ' : '') . "Replacing {$legacyRoles->count()} legacy role(s) by '{$defaultName}':");
|
||||
|
||||
foreach ($legacyRoles as $legacy) {
|
||||
preg_match(self::LEGACY_PATTERN, $legacy->name, $m);
|
||||
$adminId = (int) $m[1];
|
||||
$exists = Admin::whereKey($adminId)->exists();
|
||||
|
||||
$holderIds = DB::table('model_has_roles')
|
||||
->where('role_id', $legacy->id)
|
||||
->where('model_type', User::class)
|
||||
->pluck('model_id');
|
||||
|
||||
$this->line(" {$legacy->name}: {$holderIds->count()} holder(s)" . ($exists ? '' : ' (admin profile no longer exists)'));
|
||||
|
||||
if ($exists) {
|
||||
foreach (User::whereKey($holderIds)->get() as $user) {
|
||||
$target = "Admin\\{$adminId}\\{$defaultName}";
|
||||
if ($user->hasRole($target)) {
|
||||
$this->line(" user #{$user->id} already has {$target}");
|
||||
continue;
|
||||
}
|
||||
$this->line(" user #{$user->id} gets {$target}");
|
||||
if (!$dryRun) {
|
||||
$definition->assignScopedTo($user, $adminId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(" remove {$legacy->name}");
|
||||
if (!$dryRun) {
|
||||
$legacy->delete();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->info('Done.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* One-off: give incomplete_at a plausible historical date.
|
||||
*
|
||||
* profiles:mark-incomplete stamps "now" the first time it runs, so every
|
||||
* profile that was already incomplete shares a single timestamp and the
|
||||
* statistics chart shows one vertical step instead of a history.
|
||||
*
|
||||
* IMPORTANT: the database records no history of profile completeness — the
|
||||
* fields the rule reads are mutable and carry no audit trail. Any backdated
|
||||
* date is therefore an estimate, not a fact. Registration date is used as the
|
||||
* best defensible approximation: a profile that is incomplete today and has
|
||||
* never been filled in was, in all likelihood, incomplete from the start. It
|
||||
* overstates history for the rarer case of a profile that was once complete
|
||||
* and later emptied.
|
||||
*
|
||||
* Only touches rows stamped by the initial bulk run (same day), so it cannot
|
||||
* overwrite a genuine timestamp recorded by a later nightly run.
|
||||
*/
|
||||
class BackdateIncompleteProfiles extends Command
|
||||
{
|
||||
protected $signature = 'profiles:backdate-incomplete
|
||||
{--dry-run : Report what would change without writing}
|
||||
{--on= : Only backdate rows stamped on this date (Y-m-d); defaults to today}';
|
||||
|
||||
protected $description = 'Estimate when each incomplete profile became incomplete, so the statistics show a history';
|
||||
|
||||
private const TYPES = [User::class, Organization::class];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$on = $this->option('on')
|
||||
? \Carbon\Carbon::parse($this->option('on'))->startOfDay()
|
||||
: now()->startOfDay();
|
||||
|
||||
$this->warn('Backdated dates are estimates: profile completeness has no recorded history.');
|
||||
$this->line('Only rows stamped on ' . $on->toDateString() . ' are touched.');
|
||||
|
||||
$changed = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach (self::TYPES as $type) {
|
||||
$table = (new $type)->getTable();
|
||||
|
||||
if (!\Schema::hasColumn($table, 'incomplete_at')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type::query()
|
||||
->whereNotNull('incomplete_at')
|
||||
->whereBetween('incomplete_at', [$on, $on->copy()->endOfDay()])
|
||||
->chunkById(500, function ($profiles) use (&$changed, &$skipped, $dryRun) {
|
||||
foreach ($profiles as $profile) {
|
||||
$estimate = $profile->created_at;
|
||||
|
||||
// Nothing to move it to, or it would land in the
|
||||
// future: leave the bulk stamp alone.
|
||||
if (!$estimate || $estimate->gte($profile->incomplete_at)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$profile->incomplete_at = $estimate;
|
||||
$profile->saveQuietly();
|
||||
}
|
||||
|
||||
$changed++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$this->info($dryRun
|
||||
? "Dry run: {$changed} profiles would be backdated, {$skipped} left as they are."
|
||||
: "Backdated {$changed} profiles, left {$skipped} as they are.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?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;
|
||||
|
||||
class BackfillBankClientsByLocation extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bank-clients:backfill-by-location {--dry-run : Preview the assignments without writing to the database}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Link Users/Organizations to a local (level 2) Bank via bank_clients, matched by shared city_id on their Location record';
|
||||
|
||||
/**
|
||||
* The console command help text, shown by `php artisan help bank-clients:backfill-by-location`.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $help = <<<'HELP'
|
||||
Populates the bank_clients pivot table, which bank-manager features (the
|
||||
scoped "Manage Profiles" view, and the transaction reversal feature) use to
|
||||
determine which Users/Organizations belong to their bank.
|
||||
|
||||
bank_clients is never populated by normal registration, so on any
|
||||
environment where these features are newly enabled, most or all profiles
|
||||
will have no bank client link yet, and a bank-manager's scoped views will
|
||||
appear empty even for profiles that clearly belong to that bank's city.
|
||||
|
||||
WHAT IT DOES
|
||||
For every level-2 Bank that has a Location with a city_id set, this finds
|
||||
every User/Organization whose own Location has a matching city_id, and
|
||||
links them via a "local" bank_clients row -- unless that profile already
|
||||
has one, in which case it is skipped.
|
||||
|
||||
Only Users and Organizations are ever linked as clients. Banks, Admins,
|
||||
and banks at level 0 (central) or level 1 (system/bridge) are never
|
||||
targets or clients.
|
||||
|
||||
SAFETY
|
||||
Idempotent -- safe to run repeatedly. Already-linked profiles are always
|
||||
skipped, never duplicated or reassigned. Never deletes or modifies an
|
||||
existing bank_clients row. Profiles whose city does not match any bank
|
||||
are simply left unlinked.
|
||||
|
||||
EXAMPLES
|
||||
Preview only, no database writes:
|
||||
php artisan bank-clients:backfill-by-location --dry-run
|
||||
|
||||
Apply for real:
|
||||
php artisan bank-clients:backfill-by-location
|
||||
|
||||
PRODUCTION USAGE
|
||||
Always run with --dry-run first and check that every bank you expect to
|
||||
receive clients appears in the printed "Bank #... -> city_id ..." list.
|
||||
A missing bank usually means its Location has no city_id set -- fix that
|
||||
on the bank's profile first, then re-run the dry-run before applying.
|
||||
|
||||
See references/BANK_CLIENTS_BACKFILL_COMMAND.md for full documentation.
|
||||
HELP;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$banks = Bank::where('level', 2)->get()->filter(function (Bank $bank) {
|
||||
return (bool) $bank->locations()->whereNotNull('city_id')->exists();
|
||||
});
|
||||
|
||||
if ($banks->isEmpty()) {
|
||||
$this->error('No level-2 banks with a city_id location found. Nothing to do.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$bankCityMap = [];
|
||||
foreach ($banks as $bank) {
|
||||
$cityId = $bank->locations()->whereNotNull('city_id')->value('city_id');
|
||||
$bankCityMap[$cityId] = $bank;
|
||||
$this->line("Bank #{$bank->id} \"{$bank->name}\" -> city_id {$cityId}");
|
||||
}
|
||||
|
||||
$cityIds = array_keys($bankCityMap);
|
||||
|
||||
$this->newLine();
|
||||
$this->info($dryRun ? 'DRY RUN — no changes will be written.' : 'Writing bank_clients rows.');
|
||||
$this->newLine();
|
||||
|
||||
$totalLinked = 0;
|
||||
$totalSkippedAlreadyLinked = 0;
|
||||
|
||||
foreach (['user' => User::class, 'organization' => Organization::class] as $label => $modelClass) {
|
||||
$matches = Location::where('locatable_type', $modelClass)
|
||||
->whereIn('city_id', $cityIds)
|
||||
->get(['locatable_id', 'city_id']);
|
||||
|
||||
$this->info(ucfirst($label) . "s matched by city: {$matches->count()}");
|
||||
|
||||
$bar = $this->output->createProgressBar($matches->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($matches as $location) {
|
||||
$bank = $bankCityMap[$location->city_id];
|
||||
$client = $modelClass::find($location->locatable_id);
|
||||
|
||||
if (!$client) {
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
$alreadyLinked = $client->banksClient()->exists();
|
||||
if ($alreadyLinked) {
|
||||
$totalSkippedAlreadyLinked++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$client->attachBankClient($bank, 'local');
|
||||
}
|
||||
$totalLinked++;
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
}
|
||||
|
||||
$this->info("Linked: {$totalLinked}");
|
||||
$this->info("Skipped (already linked): {$totalSkippedAlreadyLinked}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->comment('Run without --dry-run to apply these changes.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
<?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, 'polls' => 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', 'poll.questions', 'poll.pollable', '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,
|
||||
'poll' => 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']++;
|
||||
}
|
||||
|
||||
// Poll definition only: no votes, no ledger
|
||||
if ($post->poll) {
|
||||
$postData['poll'] = \App\Polls\PollBackup::export($post->poll);
|
||||
$counts['polls']++;
|
||||
}
|
||||
|
||||
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']],
|
||||
['Polls', $counts['polls']],
|
||||
['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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MailingBounce;
|
||||
use App\Models\Mailing;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BounceSimulate extends Command
|
||||
{
|
||||
protected $signature = 'bounce:simulate
|
||||
{--email= : Email address to simulate bounce for (required unless --imap is used)}
|
||||
{--type=hard : Bounce type: hard, soft, complaint}
|
||||
{--reason= : Bounce reason string (defaults to a definitive hard-bounce pattern)}
|
||||
{--mailing-id= : Optional mailing ID to link the bounce to}
|
||||
{--clear : Remove all bounce records for this email first}
|
||||
{--suppress : Directly suppress without going through thresholds}
|
||||
{--status : Show current bounce status only, do not record anything}
|
||||
{--imap : Read real bounce emails from the IMAP mailbox configured in .env}
|
||||
{--dry-run : With --imap: parse and display bounces without recording them}
|
||||
{--delete : With --imap: delete processed emails from the mailbox}';
|
||||
|
||||
protected $description = 'Simulate or process bounced emails to test the bounce/suppression pipeline.
|
||||
|
||||
INJECT MODE (local dev — no mailbox needed):
|
||||
php artisan bounce:simulate --email=user@example.com
|
||||
Record one hard bounce (default reason: "550 5.1.1 user unknown").
|
||||
With suppression_threshold=1 this immediately suppresses the address.
|
||||
|
||||
php artisan bounce:simulate --email=user@example.com --status
|
||||
Show current bounce counts and suppression state, record nothing.
|
||||
|
||||
php artisan bounce:simulate --email=user@example.com --clear
|
||||
Delete all bounce records for this address, then show status.
|
||||
|
||||
php artisan bounce:simulate --email=user@example.com --clear --type=soft
|
||||
Start clean, record a soft bounce (does NOT trigger suppression).
|
||||
|
||||
php artisan bounce:simulate --email=user@example.com --suppress
|
||||
Directly suppress the address, bypassing threshold logic.
|
||||
|
||||
php artisan bounce:simulate --email=user@example.com --clear --type=hard --reason="mailbox unavailable" --mailing-id=3
|
||||
Start clean, record a hard bounce linked to mailing #3 with a custom reason.
|
||||
|
||||
IMAP MODE (production — reads real bounce emails from mailbox):
|
||||
php artisan bounce:simulate --imap --dry-run
|
||||
Connect to the IMAP mailbox from .env, parse bounce emails, show results — record nothing.
|
||||
|
||||
php artisan bounce:simulate --imap
|
||||
Connect, parse, and record all bounces found in the mailbox.
|
||||
|
||||
php artisan bounce:simulate --imap --delete
|
||||
Connect, parse, record, and delete processed emails from the mailbox.
|
||||
|
||||
Skips gracefully if IMAP credentials are not set in .env.
|
||||
|
||||
Typical test flow (local):
|
||||
1. bounce:simulate --email=X --status # check baseline
|
||||
2. bounce:simulate --email=X # record a hard bounce
|
||||
3. mailings:manage-bounces stats # inspect suppression list
|
||||
4. bounce:simulate --email=X --clear # reset for next test
|
||||
|
||||
Typical test flow (production):
|
||||
1. bounce:simulate --imap --dry-run # verify IMAP connection and parsing
|
||||
2. bounce:simulate --imap --delete # process and clear mailbox';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('imap')) {
|
||||
return $this->handleImap();
|
||||
}
|
||||
|
||||
return $this->handleInject();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Inject mode
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function handleInject(): int
|
||||
{
|
||||
$email = $this->option('email');
|
||||
|
||||
if (!$email) {
|
||||
$this->error('--email is required (or use --imap to read from the mailbox).');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('clear')) {
|
||||
$deleted = MailingBounce::where('email', $email)->delete();
|
||||
$this->line("Cleared {$deleted} bounce record(s) for <comment>{$email}</comment>.");
|
||||
}
|
||||
|
||||
$this->printStatus($email, 'Before');
|
||||
|
||||
if ($this->option('status')) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('suppress')) {
|
||||
MailingBounce::suppressEmail($email, 'Manually suppressed via bounce:simulate');
|
||||
$this->newLine();
|
||||
$this->info('Directly suppressed.');
|
||||
$this->printStatus($email, 'After');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$type = $this->option('type');
|
||||
$reason = $this->option('reason') ?? $this->defaultReason($type);
|
||||
$mailingId = $this->option('mailing-id') ? (int) $this->option('mailing-id') : null;
|
||||
|
||||
if ($mailingId && !Mailing::find($mailingId)) {
|
||||
$this->warn("Mailing ID {$mailingId} does not exist — recording with mailing_id = null.");
|
||||
$mailingId = null;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line("Recording <comment>{$type}</comment> bounce for <comment>{$email}</comment>");
|
||||
$this->line("Reason: <comment>{$reason}</comment>");
|
||||
if ($mailingId) {
|
||||
$this->line("Mailing ID: <comment>{$mailingId}</comment>");
|
||||
}
|
||||
|
||||
$isDefinitive = MailingBounce::testPatternMatching($type, $reason);
|
||||
$this->line("Matches definitive hard-bounce pattern: <comment>" . ($isDefinitive ? 'yes' : 'no') . "</comment>");
|
||||
|
||||
MailingBounce::recordBounce($email, $type, $reason, $mailingId);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Bounce recorded.');
|
||||
$this->printStatus($email, 'After');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IMAP mode
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function handleImap(): int
|
||||
{
|
||||
$config = $this->getImapConfig();
|
||||
|
||||
if (!$config) {
|
||||
$this->warn('IMAP credentials not configured in .env — skipping IMAP mode.');
|
||||
$this->line('Set BOUNCE_HOST, BOUNCE_USERNAME, BOUNCE_PASSWORD (and optionally BOUNCE_PORT, BOUNCE_SSL) to enable.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
$delete = $this->option('delete');
|
||||
|
||||
$this->line('Connecting to IMAP mailbox: <comment>' . $config['host'] . '</comment> as <comment>' . $config['username'] . '</comment>');
|
||||
if ($dryRun) {
|
||||
$this->line('<fg=yellow>Dry run — no bounces will be recorded.</>');
|
||||
}
|
||||
|
||||
try {
|
||||
$connection = $this->imapConnect($config);
|
||||
} catch (\Exception $e) {
|
||||
$this->error('IMAP connection failed: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$messages = $this->imapFetchBounces($connection);
|
||||
|
||||
if (empty($messages)) {
|
||||
$this->info('No bounce emails found in mailbox.');
|
||||
imap_close($connection);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->line("Found <comment>" . count($messages) . "</comment> bounce email(s).");
|
||||
$this->newLine();
|
||||
|
||||
$recorded = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
$bounce = $this->imapParseBounce($msg);
|
||||
|
||||
if (!$bounce) {
|
||||
$this->warn(" [#{$msg['id']}] Could not parse bounce — skipping.");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(" [#{$msg['id']}] <comment>{$bounce['type']}</comment> bounce → <comment>{$bounce['email']}</comment>");
|
||||
$this->line(" Reason: {$bounce['reason']}");
|
||||
if ($bounce['mailing_id']) {
|
||||
$this->line(" Mailing ID: {$bounce['mailing_id']}");
|
||||
}
|
||||
|
||||
$isDefinitive = MailingBounce::testPatternMatching($bounce['type'], $bounce['reason']);
|
||||
$this->line(" Definitive hard-bounce pattern: <comment>" . ($isDefinitive ? 'yes' : 'no') . "</comment>");
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->printStatus($bounce['email'], 'Before');
|
||||
MailingBounce::recordBounce($bounce['email'], $bounce['type'], $bounce['reason'], $bounce['mailing_id']);
|
||||
$this->printStatus($bounce['email'], 'After');
|
||||
|
||||
if ($delete) {
|
||||
imap_delete($connection, $msg['id']);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$recorded++;
|
||||
}
|
||||
|
||||
if ($delete && !$dryRun) {
|
||||
imap_expunge($connection);
|
||||
}
|
||||
|
||||
imap_close($connection);
|
||||
|
||||
$this->info("Done. Processed: {$recorded}, skipped: {$skipped}" . ($dryRun ? ' (dry run)' : '') . ($delete && !$dryRun ? ', deleted from mailbox.' : '.'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function getImapConfig(): ?array
|
||||
{
|
||||
$host = env('BOUNCE_HOST');
|
||||
$username = env('BOUNCE_USERNAME');
|
||||
$password = env('BOUNCE_PASSWORD');
|
||||
|
||||
if (!$host || !$username || !$password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'port' => env('BOUNCE_PORT', 993),
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'ssl' => env('BOUNCE_SSL', true),
|
||||
'protocol' => env('BOUNCE_PROTOCOL', 'imap'),
|
||||
];
|
||||
}
|
||||
|
||||
private function imapConnect(array $config)
|
||||
{
|
||||
$ssl = filter_var($config['ssl'], FILTER_VALIDATE_BOOLEAN) ? '/ssl' : '';
|
||||
$mailbox = "{{$config['host']}:{$config['port']}/imap{$ssl}}INBOX";
|
||||
|
||||
$connection = imap_open($mailbox, $config['username'], $config['password']);
|
||||
|
||||
if (!$connection) {
|
||||
throw new \Exception(imap_last_error());
|
||||
}
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
private function imapFetchBounces($connection): array
|
||||
{
|
||||
$messages = [];
|
||||
$count = imap_num_msg($connection);
|
||||
|
||||
for ($i = 1; $i <= $count; $i++) {
|
||||
$header = imap_headerinfo($connection, $i);
|
||||
$subject = $header->subject ?? '';
|
||||
$rawHeaders = imap_fetchheader($connection, $i);
|
||||
|
||||
if ($this->imapIsBounce($header, $rawHeaders)) {
|
||||
$messages[] = [
|
||||
'id' => $i,
|
||||
'subject' => $subject,
|
||||
'body' => imap_body($connection, $i),
|
||||
'header' => $header,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
private function imapIsBounce($header, string $rawHeaders): bool
|
||||
{
|
||||
// Must be a genuine RFC 3464 DSN — multipart/report with delivery-status
|
||||
$ct = strtolower($rawHeaders);
|
||||
if (!str_contains($ct, 'multipart/report') || !str_contains($ct, 'report-type=delivery-status')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fromMailbox = strtolower($header->from[0]->mailbox ?? '');
|
||||
$fromFull = $fromMailbox . '@' . strtolower($header->from[0]->host ?? '');
|
||||
|
||||
foreach (['mailer-daemon', 'postmaster', 'mail-daemon'] as $sender) {
|
||||
if (str_contains($fromMailbox, $sender) || str_contains($fromFull, $sender)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function imapParseBounce(array $msg): ?array
|
||||
{
|
||||
$body = $msg['body'];
|
||||
$subject = $msg['subject'];
|
||||
|
||||
$email = $this->imapExtractEmail($body);
|
||||
if (!$email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'email' => $email,
|
||||
'type' => $this->imapDetermineBounceType($body, $subject),
|
||||
'reason' => $this->imapExtractReason($body, $subject),
|
||||
'mailing_id' => $this->imapExtractMailingId($body),
|
||||
];
|
||||
}
|
||||
|
||||
private function imapExtractEmail(string $body): ?string
|
||||
{
|
||||
$patterns = [
|
||||
'/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',
|
||||
'/(?:to|for|recipient):\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',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $body, $matches)) {
|
||||
return strtolower(trim($matches[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function imapDetermineBounceType(string $body, string $subject): string
|
||||
{
|
||||
$text = strtolower($body . ' ' . $subject);
|
||||
|
||||
foreach (['user unknown', 'no such user', 'invalid recipient', 'recipient address rejected',
|
||||
'mailbox unavailable', 'does not exist', '5.1.1', '5.1.2', '5.1.3',
|
||||
'550', '551', '553', '554'] as $pattern) {
|
||||
if (str_contains($text, $pattern)) {
|
||||
return 'hard';
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['mailbox full', 'quota exceeded', 'temporarily rejected', 'try again later',
|
||||
'temporarily unavailable', '4.2.2', '4.3.1', '4.3.2',
|
||||
'421', '450', '451', '452'] as $pattern) {
|
||||
if (str_contains($text, $pattern)) {
|
||||
return 'soft';
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private function imapExtractReason(string $body, string $subject): string
|
||||
{
|
||||
if (preg_match('/diagnostic-code:\s*(.+)/i', $body, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
if (preg_match('/action:\s*failed.+status:\s*(.+)/i', $body, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
return substr($subject, 0, 255);
|
||||
}
|
||||
|
||||
private function imapExtractMailingId(string $body): ?int
|
||||
{
|
||||
if (preg_match('/x-mailing-id[:\s]*(\d+)/i', $body, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
if (preg_match('/mailing[_-]?id[:\s]*(\d+)/i', $body, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
if (preg_match('/mailing\s+#(\d+)/i', $body, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function printStatus(string $email, string $label): void
|
||||
{
|
||||
$stats = MailingBounce::getBounceStats($email);
|
||||
$config = timebank_config('mailing.bounce_thresholds', []);
|
||||
|
||||
$this->newLine();
|
||||
$this->line("=== {$label}: <comment>{$email}</comment> ===");
|
||||
$this->line(" Total bounces : {$stats['total_bounces']}");
|
||||
$this->line(" Recent bounces : {$stats['recent_bounces']} (window: {$stats['window_days']} days)");
|
||||
$this->line(" Recent definitive hard : {$stats['recent_hard_bounces']}");
|
||||
$this->line(" Suppressed : " . ($stats['is_suppressed'] ? '<fg=red>YES</>' : '<fg=green>no</>'));
|
||||
$this->line(" Suppression threshold : " . ($config['suppression_threshold'] ?? '?'));
|
||||
$this->line(" Verif. reset threshold : " . ($config['verification_reset_threshold'] ?? '?'));
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
private function defaultReason(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'hard' => '550 5.1.1 user unknown',
|
||||
'soft' => '452 mailbox full',
|
||||
'complaint' => 'Spam complaint',
|
||||
default => 'Unknown bounce reason',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
/**
|
||||
* Bulk Migrate Organizations to Users Command
|
||||
*
|
||||
* Converts all active (non-deleted) organization profiles to user profiles,
|
||||
* excluding any organization IDs specified via --exclude.
|
||||
*
|
||||
* Delegates each individual migration to MigrateOrganizationToUserCommand,
|
||||
* reusing all its validation, relationship migration, and safety logic.
|
||||
*
|
||||
* Usage examples:
|
||||
* php artisan migrate:organizations-to-users --dry-run
|
||||
* php artisan migrate:organizations-to-users --exclude=5 --exclude=12
|
||||
* php artisan migrate:organizations-to-users --exclude=5,12,34 --force
|
||||
* php artisan migrate:organizations-to-users --only=3,7,9
|
||||
* php artisan migrate:organizations-to-users --only=3 --only=7 --only=9
|
||||
* php artisan migrate:organizations-to-users --skip-errors
|
||||
*/
|
||||
class BulkMigrateOrganizationsToUsersCommand extends Command
|
||||
{
|
||||
protected $signature = 'migrate:organizations-to-users
|
||||
{--exclude=* : Organization IDs to skip (use --exclude=1 --exclude=2 or --exclude=1,2,3)}
|
||||
{--only=* : Only convert these organization IDs, ignoring all others (use --only=1 --only=2 or --only=1,2,3)}
|
||||
{--dry-run : Preview all conversions without executing them}
|
||||
{--force : Skip confirmation prompts}
|
||||
{--skip-errors : Continue to the next organization if one fails instead of stopping}';
|
||||
|
||||
protected $description = 'Bulk convert all active organization profiles to user profiles, with optional exclusions';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$force = $this->option('force');
|
||||
$skipErrors = $this->option('skip-errors');
|
||||
|
||||
$excludeIds = $this->parseIds($this->option('exclude'));
|
||||
$onlyIds = $this->parseIds($this->option('only'));
|
||||
|
||||
if (!empty($excludeIds) && !empty($onlyIds)) {
|
||||
$this->error('--exclude and --only cannot be used together.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('DRY RUN MODE - No changes will be made');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// Fetch all active organizations, filtered by --only or --exclude
|
||||
$query = Organization::whereNull('deleted_at');
|
||||
if (!empty($onlyIds)) {
|
||||
$query->whereIn('id', $onlyIds);
|
||||
} elseif (!empty($excludeIds)) {
|
||||
$query->whereNotIn('id', $excludeIds);
|
||||
}
|
||||
|
||||
$organizations = $query->orderBy('id')->get(['id', 'name', 'email']);
|
||||
|
||||
$totalActive = Organization::whereNull('deleted_at')->count();
|
||||
$excluded = $totalActive - $organizations->count();
|
||||
|
||||
$this->info("Active organizations: {$totalActive}");
|
||||
$this->info("Excluded: {$excluded}");
|
||||
$this->info("To convert: {$organizations->count()}");
|
||||
$this->newLine();
|
||||
|
||||
if ($organizations->isEmpty()) {
|
||||
$this->info('No organizations to convert.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// List the organizations that will be converted
|
||||
$this->table(
|
||||
['ID', 'Name', 'Email'],
|
||||
$organizations->map(fn($o) => [$o->id, $o->name, $o->email])->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
|
||||
if (!empty($onlyIds)) {
|
||||
$this->line('Only IDs: ' . implode(', ', $onlyIds));
|
||||
$this->newLine();
|
||||
} elseif (!empty($excludeIds)) {
|
||||
$this->line('Excluded IDs: ' . implode(', ', $excludeIds));
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if (!$dryRun && !$force) {
|
||||
if (!$this->confirm("Convert all {$organizations->count()} organization(s) to user profiles? This cannot be easily undone.", false)) {
|
||||
$this->info('Bulk migration cancelled.');
|
||||
return 0;
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// Process each organization
|
||||
$results = [];
|
||||
$successCount = 0;
|
||||
$failCount = 0;
|
||||
|
||||
foreach ($organizations as $org) {
|
||||
$this->line("Processing: [{$org->id}] {$org->name}");
|
||||
|
||||
$args = [
|
||||
'organization_id' => $org->id,
|
||||
'--force' => true,
|
||||
];
|
||||
|
||||
if ($dryRun) {
|
||||
$args['--dry-run'] = true;
|
||||
}
|
||||
|
||||
try {
|
||||
$exitCode = Artisan::call('migrate:organization-to-user', $args);
|
||||
$output = Artisan::output();
|
||||
|
||||
if ($exitCode === 0) {
|
||||
$results[] = [$org->id, $org->name, $dryRun ? 'preview ok' : 'converted'];
|
||||
$successCount++;
|
||||
// Indent the sub-command output
|
||||
foreach (explode("\n", trim($output)) as $line) {
|
||||
if ($line !== '') {
|
||||
$this->line(' ' . $line);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$results[] = [$org->id, $org->name, 'FAILED'];
|
||||
$failCount++;
|
||||
$this->error(" Failed (exit code {$exitCode}):");
|
||||
foreach (explode("\n", trim($output)) as $line) {
|
||||
if ($line !== '') {
|
||||
$this->error(' ' . $line);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$skipErrors) {
|
||||
$this->newLine();
|
||||
$this->error('Stopping due to failure. Use --skip-errors to continue past failures.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$results[] = [$org->id, $org->name, 'EXCEPTION'];
|
||||
$failCount++;
|
||||
$this->error(" Exception: " . $e->getMessage());
|
||||
|
||||
if (!$skipErrors) {
|
||||
$this->newLine();
|
||||
$this->error('Stopping due to exception. Use --skip-errors to continue past failures.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// Final summary
|
||||
$this->line('─────────────────────────────────────────');
|
||||
$this->info('Summary:');
|
||||
$this->table(
|
||||
['ID', 'Name', 'Result'],
|
||||
$results
|
||||
);
|
||||
$this->newLine();
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("Dry run complete. {$successCount} organization(s) previewed, {$failCount} would fail.");
|
||||
$this->info('Run without --dry-run to execute the migrations.');
|
||||
} else {
|
||||
$this->info("Bulk migration complete. {$successCount} converted, {$failCount} failed.");
|
||||
}
|
||||
|
||||
return $failCount > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an ID list option, supporting both repeated flags and comma-separated values.
|
||||
* e.g. --exclude=1 --exclude=2 OR --exclude=1,2,3
|
||||
*/
|
||||
private function parseIds(array $rawExclude): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($rawExclude as $value) {
|
||||
foreach (explode(',', $value) as $part) {
|
||||
$part = trim($part);
|
||||
if (is_numeric($part) && (int) $part > 0) {
|
||||
$ids[] = (int) $part;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_unique($ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Daily TLS certificate expiry monitor.
|
||||
*
|
||||
* Inspects the Let's Encrypt certificates under the configured live path and
|
||||
* emails a two-tier warning (NOTICE then URGENT) as expiry approaches, well
|
||||
* ahead of the renewal window, so a silent renewal failure is surfaced before
|
||||
* the certificate actually lapses.
|
||||
*
|
||||
* The alert is sent synchronously via Mail::raw() (never queued) so it does not
|
||||
* depend on a running queue worker, and every warning line is also written to
|
||||
* the Laravel log as a fallback. Scheduled in app/Console/Kernel.php; an
|
||||
* optional healthchecks.io dead-man's-switch is wired via the scheduler ping.
|
||||
*/
|
||||
class CheckCertExpiry extends Command
|
||||
{
|
||||
protected $signature = 'cert:check
|
||||
{--dry-run : Print findings without sending email}
|
||||
{--notice-days= : Override the NOTICE threshold (for testing)}
|
||||
{--urgent-days= : Override the URGENT threshold (for testing)}';
|
||||
protected $description = 'Check Let\'s Encrypt certificate expiry and email a two-tier warning as it approaches.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! timebank_config('cert_monitor.enabled', true)) {
|
||||
$this->info('Certificate monitor is disabled (cert_monitor.enabled=false).');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$livePath = timebank_config('cert_monitor.live_path', '/etc/letsencrypt/live');
|
||||
$noticeDays = (int) ($this->option('notice-days') ?? timebank_config('cert_monitor.notice_days', 20));
|
||||
$urgentDays = (int) ($this->option('urgent-days') ?? timebank_config('cert_monitor.urgent_days', 10));
|
||||
$email = timebank_config('cert_monitor.alert_email') ?: timebank_config('mail.system_admin.email');
|
||||
|
||||
$certs = glob(rtrim($livePath, '/') . '/*/cert.pem');
|
||||
if (empty($certs)) {
|
||||
$this->warn("No certificates found under {$livePath}.");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
$urgent = false;
|
||||
|
||||
foreach ($certs as $cert) {
|
||||
$domain = basename(dirname($cert));
|
||||
$expiry = $this->certExpiry($cert);
|
||||
|
||||
if ($expiry === null) {
|
||||
$this->warn("Could not read expiry for {$domain} ({$cert}).");
|
||||
continue;
|
||||
}
|
||||
|
||||
$days = (int) floor((($expiry->getTimestamp()) - time()) / 86400);
|
||||
|
||||
if ($days < $urgentDays) {
|
||||
$warnings[] = sprintf('URGENT: %s expires in %d days (%s)', $domain, $days, $expiry->format('Y-m-d H:i:s T'));
|
||||
$urgent = true;
|
||||
} elseif ($days < $noticeDays) {
|
||||
$warnings[] = sprintf('NOTICE: %s expires in %d days (%s)', $domain, $days, $expiry->format('Y-m-d H:i:s T'));
|
||||
} else {
|
||||
$this->line(sprintf('OK: %s expires in %d days.', $domain, $days));
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($warnings)) {
|
||||
$this->info('All certificates are outside the warning window.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$body = implode("\n", $warnings);
|
||||
|
||||
// Fallback that survives mail failure: always record to the app log.
|
||||
foreach ($warnings as $line) {
|
||||
\Log::warning('[cert-monitor] ' . $line);
|
||||
}
|
||||
|
||||
$this->warn($body);
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->comment('Dry run - no email sent.');
|
||||
return $urgent ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
$priority = $urgent ? '[URGENT]' : '[NOTICE]';
|
||||
$subject = $priority . ' ' . config('app.name') . ' TLS certificate expiry';
|
||||
|
||||
try {
|
||||
// Synchronous send (not queued) so the alert does not depend on a worker.
|
||||
Mail::raw(
|
||||
$body . "\n\nThis is an automated message from the TLS certificate monitor.",
|
||||
function ($message) use ($email, $subject) {
|
||||
$message->to($email)->subject($subject);
|
||||
}
|
||||
);
|
||||
$this->info("Alert email sent to {$email}.");
|
||||
} catch (\Throwable $e) {
|
||||
// Logged above already; surface the mail failure for the scheduler output.
|
||||
\Log::error('[cert-monitor] Failed to send alert email: ' . $e->getMessage());
|
||||
$this->error('Failed to send alert email: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Nonzero on URGENT so the scheduler/healthcheck can distinguish severity.
|
||||
return $urgent ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a certificate's notAfter date. Uses the openssl extension if available,
|
||||
* otherwise shells out to the openssl binary.
|
||||
*/
|
||||
private function certExpiry(string $path): ?\DateTimeImmutable
|
||||
{
|
||||
if (! is_readable($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (function_exists('openssl_x509_parse')) {
|
||||
$data = openssl_x509_parse(file_get_contents($path));
|
||||
if (is_array($data) && ! empty($data['validTo_time_t'])) {
|
||||
return (new \DateTimeImmutable())->setTimestamp((int) $data['validTo_time_t']);
|
||||
}
|
||||
}
|
||||
|
||||
$out = @shell_exec('openssl x509 -enddate -noout -in ' . escapeshellarg($path) . ' 2>/dev/null');
|
||||
if ($out && preg_match('/notAfter=(.+)/', $out, $m)) {
|
||||
$ts = strtotime(trim($m[1]));
|
||||
if ($ts !== false) {
|
||||
return (new \DateTimeImmutable())->setTimestamp($ts);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CleanCyclosProfiles extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'profiles:clean-about';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Clean empty Cyclos migration paragraph markup from profile about fields';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Starting cleanup of Cyclos migration empty about fields...');
|
||||
$this->newLine();
|
||||
|
||||
$totalCleaned = 0;
|
||||
|
||||
// Clean Users
|
||||
$usersCleaned = $this->cleanProfileAbout(User::class, 'Users');
|
||||
$totalCleaned += $usersCleaned;
|
||||
|
||||
// Clean Organizations
|
||||
$organizationsCleaned = $this->cleanProfileAbout(Organization::class, 'Organizations');
|
||||
$totalCleaned += $organizationsCleaned;
|
||||
|
||||
// Clean Banks
|
||||
$banksCleaned = $this->cleanProfileAbout(Bank::class, 'Banks');
|
||||
$totalCleaned += $banksCleaned;
|
||||
|
||||
// Clean Admins
|
||||
$adminsCleaned = $this->cleanProfileAbout(Admin::class, 'Admins');
|
||||
$totalCleaned += $adminsCleaned;
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Cleanup complete! Total profiles cleaned: {$totalCleaned}");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the about field for a specific model type.
|
||||
*
|
||||
* @param string $modelClass
|
||||
* @param string $displayName
|
||||
* @return int
|
||||
*/
|
||||
private function cleanProfileAbout(string $modelClass, string $displayName): int
|
||||
{
|
||||
// Find all profiles where about field contains only the empty paragraph or single quote
|
||||
$profiles = $modelClass::where('about', '<p></p>')
|
||||
->orWhere('about', '<p> </p>')
|
||||
->orWhere('about', '<p> </p>')
|
||||
->orWhere('about', '"')
|
||||
->get();
|
||||
|
||||
$count = $profiles->count();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->line("✓ {$displayName}: No profiles to clean");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$bar = $this->output->createProgressBar($count);
|
||||
$bar->setFormat(" {$displayName}: [%bar%] %current%/%max% (%percent:3s%%)");
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$profile->about = null;
|
||||
$profile->save();
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PhpParser\Node;
|
||||
use PhpParser\ParserFactory;
|
||||
|
||||
/**
|
||||
* Merge new keys from a config's .example file into the active config.
|
||||
*
|
||||
* Works on the SOURCE, not on evaluated arrays. Each new key's text is copied
|
||||
* from the .example file - its comment and any env() call included - into the
|
||||
* same place in the active file, and nothing else in that file changes.
|
||||
*
|
||||
* It used to include both files, merge the resulting arrays and write the
|
||||
* whole active file back with var_export(). Including runs every env() call,
|
||||
* so each merge froze the values of that moment into the file - for new keys
|
||||
* and existing ones alike - and dropped every comment. On production that
|
||||
* turned cert_monitor.* and health_monitor.healthcheck_url into fixed values,
|
||||
* and a URL added to .env afterwards was never read (2026-10-07).
|
||||
*/
|
||||
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, or the TIMEBANK_CONFIG name)}
|
||||
{--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',
|
||||
];
|
||||
|
||||
/** The template for a platform config that has no .example file of its own. */
|
||||
protected $platformTemplate = 'config/timebank-default.php.example';
|
||||
|
||||
/** Name of a backup of the platform folder's config, so --restore can put it back there. */
|
||||
protected const PLATFORM_BACKUP_NAME = 'platform-config.php';
|
||||
|
||||
/**
|
||||
* The fixed list plus this installation's own platform config (TIMEBANK_CONFIG), so a platform
|
||||
* with its own name, e.g. config/myorg.php, gets new keys too. When the platform folder has a
|
||||
* config.php, that is the platform config (see PlatformServiceProvider) and the one merged.
|
||||
*/
|
||||
protected function mergeableConfigs(): array
|
||||
{
|
||||
$configs = $this->mergeableConfigs;
|
||||
$platform = (string) config('app.timebank_config');
|
||||
|
||||
if (preg_match('/^[A-Za-z0-9_-]+$/', $platform)) {
|
||||
if ($folderConfig = $this->platformFolderConfig()) {
|
||||
$configs[$platform] = $folderConfig;
|
||||
} elseif (!isset($configs[$platform])) {
|
||||
$configs[$platform] = "config/{$platform}.php";
|
||||
}
|
||||
}
|
||||
|
||||
return $configs;
|
||||
}
|
||||
|
||||
/** platform/config.php when it exists. */
|
||||
protected function platformFolderConfig(): ?string
|
||||
{
|
||||
$path = platform_path('config.php');
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
/** A platform config without an .example of its own is merged from the default template. */
|
||||
protected function examplePathFor(string $path): string
|
||||
{
|
||||
$platform = (string) config('app.timebank_config');
|
||||
|
||||
// The platform folder's config takes the template of its platform name
|
||||
if ($path === $this->platformFolderConfig()) {
|
||||
$named = "config/{$platform}.php.example";
|
||||
|
||||
return File::exists($this->absolutePath($named)) ? $named : $this->platformTemplate;
|
||||
}
|
||||
|
||||
$own = $path . '.example';
|
||||
if (File::exists($this->absolutePath($own))) {
|
||||
return $own;
|
||||
}
|
||||
|
||||
if (basename($path) === "{$platform}.php" && !isset($this->mergeableConfigs[$platform])) {
|
||||
return $this->platformTemplate;
|
||||
}
|
||||
|
||||
return $own;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [];
|
||||
}
|
||||
|
||||
$configs = $this->mergeableConfigs();
|
||||
if (!isset($configs[$file])) {
|
||||
$this->error("Unknown config file: {$file}");
|
||||
$this->line('Available files: ' . implode(', ', array_keys($configs)));
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$file => $configs[$file]];
|
||||
}
|
||||
|
||||
/** Relative to the project, not to wherever the command was started. */
|
||||
protected function absolutePath(string $path): string
|
||||
{
|
||||
return str_starts_with($path, '/') ? $path : base_path($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a single config file
|
||||
*/
|
||||
protected function mergeConfigFile(string $name, string $path): array
|
||||
{
|
||||
$shownPath = $path;
|
||||
$shownExample = $this->examplePathFor($path);
|
||||
$path = $this->absolutePath($path);
|
||||
$examplePath = $this->absolutePath($shownExample);
|
||||
|
||||
// Check if files exist
|
||||
if (!File::exists($examplePath)) {
|
||||
$this->warn("⊘ {$name}: Example file not found ({$shownExample})");
|
||||
return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0];
|
||||
}
|
||||
|
||||
if (!File::exists($path)) {
|
||||
$this->warn("⊘ {$name}: Active config not found ({$shownPath}) - run deployment first");
|
||||
return ['hasChanges' => false, 'applied' => false, 'newKeyCount' => 0];
|
||||
}
|
||||
|
||||
// Load configs
|
||||
try {
|
||||
$currentConfig = $this->loadConfig($path);
|
||||
$exampleConfig = $this->loadConfig($examplePath);
|
||||
// Taken now: comparable() reads a closure's source by line number from its file, and after
|
||||
// the merge is written the file has new lines above it.
|
||||
$currentComparable = $this->comparable($currentConfig);
|
||||
} 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 the merge on the source text.
|
||||
try {
|
||||
[$mergedSource, $skipped] = $this->mergeSource(
|
||||
File::get($path),
|
||||
File::get($examplePath),
|
||||
$this->findNewKeyPaths($currentConfig, $exampleConfig)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("✗ {$name}: Could not merge the source - {$e->getMessage()}");
|
||||
return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)];
|
||||
}
|
||||
|
||||
foreach ($skipped as $keyPath => $reason) {
|
||||
$this->warn(" ⊘ {$keyPath}: not merged - {$reason}. Add it by hand from {$shownExample}.");
|
||||
}
|
||||
|
||||
if (File::put($path, $mergedSource) === false) {
|
||||
$this->error("✗ {$name}: Failed to write merged config");
|
||||
File::copy($backupPath, $path);
|
||||
return ['hasChanges' => true, 'applied' => false, 'newKeyCount' => count($newKeys)];
|
||||
}
|
||||
|
||||
// The merged file must load, hold every merged key, and leave every
|
||||
// existing value exactly as it was.
|
||||
try {
|
||||
$merged = $this->loadConfig($path);
|
||||
$missing = array_diff(
|
||||
array_map(fn ($p) => implode('.', $p), $this->findNewKeyPaths($merged, $exampleConfig)),
|
||||
array_keys($skipped)
|
||||
);
|
||||
|
||||
if (!empty($missing)) {
|
||||
throw new \Exception('keys still missing after merge: ' . implode(', ', $missing));
|
||||
}
|
||||
|
||||
if ($this->comparable($this->withoutKeys($merged, $this->findNewKeyPaths($currentConfig, $merged)))
|
||||
!= $currentComparable) {
|
||||
throw new \Exception('existing values changed');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("✗ {$name}: Merged config is invalid - {$e->getMessage()}");
|
||||
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 ($this->descends($current[$key], $value)) {
|
||||
// Both are keyed 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether new keys are looked for inside this value. A list (footer links, payment types, validation
|
||||
* rules, mime types) is the platform's own choice as a whole: merging it by position would add the
|
||||
* template's items, or keys into the wrong item (a mailto link got the template's route). A missing
|
||||
* list is still added whole, by its parent. An empty [] still takes the keys of a keyed template array.
|
||||
*/
|
||||
protected function descends(mixed $current, mixed $example): bool
|
||||
{
|
||||
if (!is_array($current) || !is_array($example)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$isList = fn (array $value) => $value !== [] && array_is_list($value);
|
||||
|
||||
return !$isList($example) && !$isList($current);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = $path === $this->platformFolderConfig() ? self::PLATFORM_BACKUP_NAME : 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The new keys as paths of key segments, outermost new key only: a new
|
||||
* array brings its whole content along.
|
||||
*
|
||||
* @return array<int, array<int, int|string>>
|
||||
*/
|
||||
protected function findNewKeyPaths(array $current, array $example, array $prefix = []): array
|
||||
{
|
||||
$paths = [];
|
||||
|
||||
foreach ($example as $key => $value) {
|
||||
$path = [...$prefix, $key];
|
||||
|
||||
if (!array_key_exists($key, $current)) {
|
||||
$paths[] = $path;
|
||||
} elseif ($this->descends($current[$key], $value)) {
|
||||
$paths = array_merge($paths, $this->findNewKeyPaths($current[$key], $value, $path));
|
||||
}
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy each new key's source from the example into the current source.
|
||||
*
|
||||
* @param array<int, array<int, int|string>> $paths From findNewKeyPaths().
|
||||
* @return array{0: string, 1: array<string, string>} Merged source, and keys
|
||||
* that could not be placed.
|
||||
*/
|
||||
protected function mergeSource(string $current, string $example, array $paths): array
|
||||
{
|
||||
$parser = (new ParserFactory())->createForHostVersion();
|
||||
$currentRoot = $this->returnedArray($parser->parse($current));
|
||||
$exampleRoot = $this->returnedArray($parser->parse($example));
|
||||
|
||||
if (!$currentRoot || !$exampleRoot) {
|
||||
throw new \RuntimeException('a config file does not return an array literal');
|
||||
}
|
||||
|
||||
// [position => text], applied back to front so positions stay valid.
|
||||
$edits = [];
|
||||
$skipped = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
$keyPath = implode('.', $path);
|
||||
$key = end($path);
|
||||
$parentPath = array_slice($path, 0, -1);
|
||||
|
||||
$source = $this->arrayAt($exampleRoot, $parentPath);
|
||||
$target = $this->arrayAt($currentRoot, $parentPath);
|
||||
$item = $source ? $this->itemFor($source, $key) : null;
|
||||
|
||||
if (!$item || !$target) {
|
||||
$skipped[$keyPath] = $target
|
||||
? 'not found in the example source'
|
||||
: 'its parent is not an array literal in the active file';
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = $this->itemSource($example, $item);
|
||||
$close = $target->getEndFilePos();
|
||||
$lineStart = strrpos(substr($current, 0, $close), "\n");
|
||||
$lineStart = $lineStart === false ? 0 : $lineStart + 1;
|
||||
$closingOnOwnLine = trim(substr($current, $lineStart, $close - $lineStart)) === '';
|
||||
|
||||
// The last existing entry needs a comma before the new one.
|
||||
$last = null;
|
||||
foreach ($target->items as $existing) {
|
||||
if ($existing !== null) {
|
||||
$last = $existing;
|
||||
}
|
||||
}
|
||||
|
||||
if ($last) {
|
||||
$after = $last->getEndFilePos() + 1;
|
||||
$between = preg_replace('~//[^\n]*|#[^\n]*|/\*.*?\*/~s', '', substr($current, $after, $close - $after));
|
||||
|
||||
if (!str_starts_with(ltrim($between), ',')) {
|
||||
$edits[$after][] = ',';
|
||||
}
|
||||
}
|
||||
|
||||
if ($closingOnOwnLine) {
|
||||
$edits[$lineStart][] = $text . ",\n";
|
||||
} else {
|
||||
// "[]" or a one-line array: open it up.
|
||||
$indent = preg_match('/^[ \t]*/', substr($current, $lineStart), $m) ? $m[0] : '';
|
||||
$edits[$close][] = "\n" . $text . ",\n" . $indent;
|
||||
}
|
||||
}
|
||||
|
||||
krsort($edits);
|
||||
|
||||
foreach ($edits as $position => $texts) {
|
||||
$current = substr($current, 0, $position) . implode('', array_unique($texts)) . substr($current, $position);
|
||||
}
|
||||
|
||||
return [$current, $skipped];
|
||||
}
|
||||
|
||||
/** The array literal a config file returns. */
|
||||
protected function returnedArray(?array $statements): ?Node\Expr\Array_
|
||||
{
|
||||
foreach ($statements ?? [] as $statement) {
|
||||
if ($statement instanceof Node\Stmt\Return_ && $statement->expr instanceof Node\Expr\Array_) {
|
||||
return $statement->expr;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The array literal at a key path, or null when it is not one. */
|
||||
protected function arrayAt(Node\Expr\Array_ $array, array $path): ?Node\Expr\Array_
|
||||
{
|
||||
foreach ($path as $key) {
|
||||
$item = $this->itemFor($array, $key);
|
||||
|
||||
if (!$item || !$item->value instanceof Node\Expr\Array_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$array = $item->value;
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry for a key, explicit or implicit (a list's next index). When a key occurs twice, the last
|
||||
* one, as in PHP: older platform configs define profile_inactive twice, and a key merged into the
|
||||
* first block was lost.
|
||||
*/
|
||||
protected function itemFor(Node\Expr\Array_ $array, int|string $key): ?Node\ArrayItem
|
||||
{
|
||||
$next = 0;
|
||||
$found = null;
|
||||
|
||||
foreach ($array->items as $item) {
|
||||
if ($item === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($item->key === null) {
|
||||
$itemKey = $next++;
|
||||
} elseif ($item->key instanceof Node\Scalar\String_) {
|
||||
$itemKey = $item->key->value;
|
||||
} elseif ($item->key instanceof Node\Scalar\Int_) {
|
||||
$itemKey = $item->key->value;
|
||||
$next = max($next, $itemKey + 1);
|
||||
} else {
|
||||
// A computed key cannot be matched.
|
||||
continue;
|
||||
}
|
||||
|
||||
// PHP turns "10" into 10 as an array key; match it the same way.
|
||||
if ((string) $itemKey === (string) $key) {
|
||||
$found = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* An entry's source text: from the start of its first comment's line to
|
||||
* the end of its value, without a trailing comma.
|
||||
*/
|
||||
protected function itemSource(string $code, Node\ArrayItem $item): string
|
||||
{
|
||||
$start = $item->getStartFilePos();
|
||||
|
||||
foreach ($item->getComments() as $comment) {
|
||||
// A comment after code on its line ("365, // 1 year") belongs to the previous entry; the parser
|
||||
// attaches it to this one, and taking it along duplicated it.
|
||||
$commentStart = $comment->getStartFilePos();
|
||||
$commentLine = strrpos(substr($code, 0, $commentStart), "\n");
|
||||
$commentLine = $commentLine === false ? 0 : $commentLine + 1;
|
||||
if (trim(substr($code, $commentLine, $commentStart - $commentLine)) !== '') {
|
||||
continue;
|
||||
}
|
||||
$start = min($start, $commentStart);
|
||||
}
|
||||
|
||||
$lineStart = strrpos(substr($code, 0, $start), "\n");
|
||||
$lineStart = $lineStart === false ? 0 : $lineStart + 1;
|
||||
|
||||
// Only whitespace before it on its line: take the indentation along.
|
||||
if (trim(substr($code, $lineStart, $start - $lineStart)) === '') {
|
||||
$start = $lineStart;
|
||||
}
|
||||
|
||||
return substr($code, $start, $item->getEndFilePos() - $start + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a config file fresh. include, not require_once: a file included
|
||||
* twice in one run (before and after the merge) must be read again.
|
||||
*/
|
||||
protected function loadConfig(string $path): array
|
||||
{
|
||||
$config = include $path;
|
||||
|
||||
if (!is_array($config)) {
|
||||
throw new \RuntimeException("{$path} does not return an array");
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/** A copy without the given key paths. */
|
||||
protected function withoutKeys(array $config, array $paths): array
|
||||
{
|
||||
foreach ($paths as $path) {
|
||||
$ref = &$config;
|
||||
$key = array_pop($path);
|
||||
|
||||
foreach ($path as $segment) {
|
||||
$ref = &$ref[$segment];
|
||||
}
|
||||
|
||||
unset($ref[$key]);
|
||||
unset($ref);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closures compare by identity, so compare them by their source text -
|
||||
* not their line numbers, which a merge above them shifts.
|
||||
*/
|
||||
protected function comparable($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return array_map(fn ($v) => $this->comparable($v), $value);
|
||||
}
|
||||
|
||||
if ($value instanceof \Closure) {
|
||||
$rf = new \ReflectionFunction($value);
|
||||
$lines = file($rf->getFileName());
|
||||
|
||||
return 'closure:' . trim(implode('', array_slice(
|
||||
$lines,
|
||||
$rf->getStartLine() - 1,
|
||||
$rf->getEndLine() - $rf->getStartLine() + 1
|
||||
)));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
if (is_object($value)) {
|
||||
return $value instanceof \Closure ? 'function' : get_class($value);
|
||||
}
|
||||
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 = $selected['file'] === self::PLATFORM_BACKUP_NAME
|
||||
? platform_path('config.php')
|
||||
: base_path("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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,985 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
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->addManageCallsPermission();
|
||||
$this->createAdminRolesTable();
|
||||
$this->createOrganizationRolesTable();
|
||||
$this->addGranularPermissions();
|
||||
$this->seedAdminRolePresets();
|
||||
$this->updateAdministratorRolePermissions();
|
||||
$this->migrateFlatAdminRoleToScoped();
|
||||
$this->replaceLegacyAdminRoles();
|
||||
$this->migrateHardcodedOrgRolesToOrganizationRoles();
|
||||
$this->fixCoordinatorSystemRoles();
|
||||
$this->addPasswordChangeLockColumns();
|
||||
$this->addChangePasswordToManagerRoles();
|
||||
$this->addAdminCapabilityPermissions();
|
||||
$this->fixReversalTransactionTypeIcon();
|
||||
$this->addPollCategory();
|
||||
$this->addPollPermissions();
|
||||
|
||||
$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());
|
||||
}
|
||||
}
|
||||
|
||||
private function addManageCallsPermission()
|
||||
{
|
||||
$this->info('Adding "manage calls" permission...');
|
||||
|
||||
try {
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
|
||||
$permission = Permission::firstOrCreate(['name' => 'manage calls', 'guard_name' => 'web']);
|
||||
|
||||
foreach (['create calls', 'update calls', 'delete calls'] as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
|
||||
// Give permission to existing site-editor and admin roles
|
||||
foreach (['site-editor', 'admin'] as $roleName) {
|
||||
$role = Role::findByName($roleName, 'web');
|
||||
if ($role && !$role->hasPermissionTo('manage calls')) {
|
||||
$role->givePermissionTo($permission);
|
||||
$this->info(" ✓ Gave 'manage calls' to role '{$roleName}'");
|
||||
}
|
||||
}
|
||||
|
||||
// Give permission to all scoped Admin\{id}\site-editor roles
|
||||
$scopedRoles = Role::where('name', 'like', 'Admin\\%\\site-editor')->get();
|
||||
foreach ($scopedRoles as $scopedRole) {
|
||||
if (!$scopedRole->hasPermissionTo('manage calls')) {
|
||||
$scopedRole->givePermissionTo($permission);
|
||||
$this->info(" ✓ Gave 'manage calls' to scoped role '{$scopedRole->name}'");
|
||||
}
|
||||
}
|
||||
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
$this->info(' ✓ "manage calls" permission added/updated');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to add manage calls permission: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function addGranularPermissions()
|
||||
{
|
||||
$this->info('Adding granular permissions (view/publish/unpublish/send)...');
|
||||
|
||||
try {
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
|
||||
$newPermissions = [
|
||||
// view permissions for all resources
|
||||
'view posts', 'view tags', 'view categories', 'view calls',
|
||||
'view mailings', 'view users', 'view user_projects',
|
||||
'view organizations', 'view banks', 'view admins', 'view accounts',
|
||||
'view profiles', 'view permissions', 'view roles',
|
||||
// publish/unpublish calls
|
||||
'publish calls', 'unpublish calls',
|
||||
// publish/unpublish posts
|
||||
'publish posts', 'unpublish posts',
|
||||
// send mailings (irreversible dispatch)
|
||||
'send mailings',
|
||||
// additional granular actions already in seeder but possibly missing
|
||||
'create mailings', 'update mailings', 'delete mailings',
|
||||
'create calls', 'update calls', 'delete calls',
|
||||
'create posts', 'update posts', 'delete posts',
|
||||
'create tags', 'update tags', 'delete tags',
|
||||
'create categories', 'update categories', 'delete categories',
|
||||
'create users', 'update users', 'delete users',
|
||||
'create user_projects', 'update user_projects', 'delete user_projects',
|
||||
'create organizations', 'update organizations', 'delete organizations',
|
||||
'create banks', 'update banks', 'delete banks',
|
||||
'create admins', 'update admins', 'delete admins',
|
||||
'create accounts', 'update accounts', 'delete accounts',
|
||||
'create profiles', 'update profiles', 'delete profiles',
|
||||
'create permissions', 'update permissions', 'delete permissions',
|
||||
'create roles', 'update roles', 'delete roles',
|
||||
'manage maintenance',
|
||||
'view search-metrics', 'view application-log', 'view inactive-profiles-log', 'view search-reindex-log',
|
||||
'view stats',
|
||||
// Org/bank capability permissions
|
||||
'org pay', 'org transactions', 'org reports', 'org stats', 'org contacts',
|
||||
'org calls', 'org messenger', 'org edit-profile', 'org settings', 'org change-password',
|
||||
// Admin profile capability permissions
|
||||
'admin reports', 'admin contacts', 'admin messenger', 'admin settings', 'admin change-password',
|
||||
];
|
||||
|
||||
$created = 0;
|
||||
foreach ($newPermissions as $name) {
|
||||
$permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
if ($permission->wasRecentlyCreated) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
// Give all permissions to the flat admin and super-admin Spatie roles
|
||||
foreach (['admin', 'super-admin'] as $roleName) {
|
||||
$role = Role::where('name', $roleName)->where('guard_name', 'web')->first();
|
||||
if ($role) {
|
||||
$role->givePermissionTo(Permission::where('guard_name', 'web')->get());
|
||||
}
|
||||
}
|
||||
|
||||
// Give view+create+update posts/tags/categories/calls to flat site-editor role
|
||||
$siteEditorRole = Role::where('name', 'site-editor')->where('guard_name', 'web')->first();
|
||||
if ($siteEditorRole) {
|
||||
$siteEditorPerms = [
|
||||
'view posts', 'create posts', 'update posts', 'publish posts', 'unpublish posts', 'delete posts', 'manage posts',
|
||||
'view tags', 'create tags', 'update tags', 'delete tags', 'manage tags',
|
||||
'view categories', 'create categories', 'update categories', 'delete categories', 'manage categories',
|
||||
'view calls', 'create calls', 'update calls', 'publish calls', 'unpublish calls', 'delete calls', 'manage calls',
|
||||
];
|
||||
foreach ($siteEditorPerms as $permName) {
|
||||
$perm = Permission::where('name', $permName)->where('guard_name', 'web')->first();
|
||||
if ($perm && !$siteEditorRole->hasPermissionTo($permName)) {
|
||||
$siteEditorRole->givePermissionTo($perm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync all scoped Admin\{id}\{role} Spatie roles from AdminRole definitions
|
||||
if (\Schema::hasTable('admin_roles')) {
|
||||
$adminRoles = \App\Models\AdminRole::all();
|
||||
$allSpatieRoles = Role::where('guard_name', 'web')->get();
|
||||
foreach ($adminRoles as $adminRole) {
|
||||
$suffix = '\\' . $adminRole->name;
|
||||
$spatieRoles = $allSpatieRoles->filter(fn($r) => str_ends_with($r->name, $suffix));
|
||||
foreach ($spatieRoles as $spatieRole) {
|
||||
$spatieRole->syncPermissions($adminRole->permissions ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
$this->info(" ✓ Added {$created} new permission(s), synced scoped roles");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to add granular permissions: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function updateAdministratorRolePermissions()
|
||||
{
|
||||
$this->info('Updating administrator role with full granular permissions...');
|
||||
|
||||
if (!\Schema::hasTable('admin_roles')) {
|
||||
$this->warn(' ℹ admin_roles table not found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have UPDATE privilege on admin_roles
|
||||
try {
|
||||
DB::statement('UPDATE admin_roles SET updated_at = updated_at WHERE id = 0');
|
||||
} catch (\Exception $e) {
|
||||
$this->warn(' ℹ Insufficient privileges to update admin_roles. Run manually:');
|
||||
$this->warn(' sudo mysql -e "GRANT UPDATE, DELETE ON ' . config('database.connections.mysql.database') . '.admin_roles TO \''. config('database.connections.mysql.username') . '\'@\'localhost\'; FLUSH PRIVILEGES;"');
|
||||
$this->warn(' Then re-run: php artisan database:update');
|
||||
return;
|
||||
}
|
||||
|
||||
$allPermissions = [
|
||||
// Posts
|
||||
'view posts', 'create posts', 'update posts', 'publish posts', 'unpublish posts', 'delete posts', 'manage posts',
|
||||
// Tags
|
||||
'view tags', 'create tags', 'update tags', 'delete tags', 'manage tags',
|
||||
// Categories
|
||||
'view categories', 'create categories', 'update categories', 'delete categories', 'manage categories',
|
||||
// Calls
|
||||
'view calls', 'create calls', 'update calls', 'publish calls', 'unpublish calls', 'delete calls', 'manage calls',
|
||||
// Mailings
|
||||
'view mailings', 'create mailings', 'update mailings', 'send mailings', 'delete mailings', 'manage mailings',
|
||||
// Users
|
||||
'view users', 'create users', 'update users', 'delete users', 'manage users',
|
||||
// User projects
|
||||
'view user_projects', 'create user_projects', 'update user_projects', 'delete user_projects', 'manage user_projects',
|
||||
// Organizations
|
||||
'view organizations', 'create organizations', 'update organizations', 'delete organizations', 'manage organizations',
|
||||
// Banks
|
||||
'view banks', 'create banks', 'update banks', 'delete banks', 'manage banks',
|
||||
// Admins
|
||||
'view admins', 'create admins', 'update admins', 'delete admins', 'manage admins',
|
||||
// Accounts
|
||||
'view accounts', 'create accounts', 'update accounts', 'delete accounts', 'manage accounts',
|
||||
// Profiles
|
||||
'view profiles', 'create profiles', 'update profiles', 'delete profiles', 'manage profiles',
|
||||
// Permissions
|
||||
'view permissions', 'create permissions', 'update permissions', 'delete permissions', 'manage permissions',
|
||||
// Roles
|
||||
'view roles', 'create roles', 'update roles', 'delete roles', 'manage roles',
|
||||
// Maintenance & admin tools
|
||||
'manage maintenance',
|
||||
'view search-metrics', 'view application-log', 'view inactive-profiles-log', 'view search-reindex-log',
|
||||
'view stats',
|
||||
// Admin profile capabilities
|
||||
'admin reports', 'admin contacts', 'admin messenger', 'admin settings', 'admin change-password',
|
||||
];
|
||||
|
||||
try {
|
||||
$adminRole = \App\Models\AdminRole::where('name', 'administrator')->first();
|
||||
if (!$adminRole) {
|
||||
$this->warn(' ℹ administrator AdminRole not found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
$adminRole->permissions = $allPermissions;
|
||||
$adminRole->save();
|
||||
|
||||
// Sync all scoped Admin\{id}\administrator Spatie roles
|
||||
$spatieRoles = Role::where('guard_name', 'web')->get()
|
||||
->filter(fn($r) => str_ends_with($r->name, '\\administrator'));
|
||||
foreach ($spatieRoles as $spatieRole) {
|
||||
$spatieRole->syncPermissions($allPermissions);
|
||||
$this->info(" ✓ Synced permissions for scoped role '{$spatieRole->name}'");
|
||||
}
|
||||
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
$this->info(' ✓ administrator AdminRole permissions updated (' . count($allPermissions) . ' permissions)');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to update administrator role: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the legacy roles Admin\{id}\admin by the default role from admin_roles (see admin-roles:migrate).
|
||||
* Runs after migrateFlatAdminRoleToScoped(), so its administrator roles already exist.
|
||||
*/
|
||||
private function replaceLegacyAdminRoles()
|
||||
{
|
||||
$this->info('Replacing legacy Admin\\{id}\\admin roles...');
|
||||
|
||||
try {
|
||||
$this->call('admin-roles:migrate');
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(' ℹ Skipped: ' . $e->getMessage() . ' (run php artisan admin-roles:migrate --dry-run)');
|
||||
}
|
||||
}
|
||||
|
||||
private function migrateFlatAdminRoleToScoped()
|
||||
{
|
||||
$this->info('Migrating flat admin role to scoped roles...');
|
||||
|
||||
try {
|
||||
$flatAdminRole = Role::where('name', 'admin')->where('guard_name', 'web')->first();
|
||||
if (!$flatAdminRole) {
|
||||
$this->info(' ℹ No flat admin role found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all users with the flat admin role
|
||||
$userIds = DB::table('model_has_roles')
|
||||
->where('role_id', $flatAdminRole->id)
|
||||
->where('model_type', 'App\Models\User')
|
||||
->pluck('model_id');
|
||||
|
||||
if ($userIds->isEmpty()) {
|
||||
$this->info(' ℹ No users with flat admin role, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
$administratorAdminRole = \App\Models\AdminRole::where('name', 'administrator')->first();
|
||||
if (!$administratorAdminRole) {
|
||||
$this->warn(' ℹ administrator AdminRole not found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
$migrated = 0;
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$user = \App\Models\User::find($userId);
|
||||
if (!$user) continue;
|
||||
|
||||
// Find all admin profiles linked to this user
|
||||
$adminProfiles = \App\Models\Admin::whereHas('users', fn($q) => $q->where('users.id', $userId))->get();
|
||||
|
||||
// Without a link nothing is granted: guessing would give administrator on every admin profile
|
||||
if ($adminProfiles->isEmpty()) {
|
||||
$this->warn(" ℹ User {$user->name} (id:{$userId}) has the flat admin role but no admin profile link: no role assigned");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($adminProfiles as $admin) {
|
||||
$scopedRoleName = 'Admin\\' . $admin->id . '\\administrator';
|
||||
|
||||
// Get or create the scoped Spatie role
|
||||
$scopedRole = Role::firstOrCreate(
|
||||
['name' => $scopedRoleName, 'guard_name' => 'web'],
|
||||
);
|
||||
|
||||
// Sync permissions from AdminRole definition
|
||||
$scopedRole->syncPermissions($administratorAdminRole->permissions ?? []);
|
||||
|
||||
// Assign to user if not already assigned
|
||||
$exists = DB::table('model_has_roles')
|
||||
->where('role_id', $scopedRole->id)
|
||||
->where('model_id', $userId)
|
||||
->where('model_type', 'App\Models\User')
|
||||
->exists();
|
||||
|
||||
if (!$exists) {
|
||||
DB::table('model_has_roles')->insert([
|
||||
'role_id' => $scopedRole->id,
|
||||
'model_id' => $userId,
|
||||
'model_type' => 'App\Models\User',
|
||||
]);
|
||||
$this->info(" ✓ Assigned {$scopedRoleName} to user {$user->name} (id:{$userId})");
|
||||
$migrated++;
|
||||
} else {
|
||||
$this->info(" ℹ User {$user->name} already has {$scopedRoleName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
$this->info(" ✓ Migrated {$migrated} scoped role assignment(s)");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to migrate flat admin role: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function seedAdminRolePresets()
|
||||
{
|
||||
$this->info('Seeding admin role presets...');
|
||||
|
||||
if (!\Schema::hasTable('admin_roles')) {
|
||||
$this->warn(' ℹ admin_roles table not found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
$presets = [
|
||||
[
|
||||
'name' => 'content-reviewer',
|
||||
'label' => 'Content Reviewer',
|
||||
'permissions' => ['view posts', 'view tags', 'view categories', 'view calls'],
|
||||
'is_system' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'content-editor',
|
||||
'label' => 'Content Editor',
|
||||
'permissions' => [
|
||||
'view posts', 'create posts', 'update posts',
|
||||
'view tags', 'create tags', 'update tags',
|
||||
'view categories', 'create categories', 'update categories',
|
||||
'view calls', 'create calls', 'update calls',
|
||||
],
|
||||
'is_system' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'content-publisher',
|
||||
'label' => 'Content Publisher',
|
||||
'permissions' => [
|
||||
'view posts', 'create posts', 'update posts', 'publish posts', 'unpublish posts',
|
||||
'view tags', 'create tags', 'update tags',
|
||||
'view categories', 'create categories', 'update categories',
|
||||
'view calls', 'create calls', 'update calls', 'publish calls', 'unpublish calls',
|
||||
],
|
||||
'is_system' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'mailing-editor',
|
||||
'label' => 'Mailing Editor',
|
||||
'permissions' => ['view mailings', 'create mailings', 'update mailings'],
|
||||
'is_system' => false,
|
||||
],
|
||||
];
|
||||
|
||||
$seeded = 0;
|
||||
foreach ($presets as $preset) {
|
||||
$exists = DB::table('admin_roles')->where('name', $preset['name'])->exists();
|
||||
if (!$exists) {
|
||||
DB::table('admin_roles')->insert([
|
||||
'name' => $preset['name'],
|
||||
'label' => $preset['label'],
|
||||
'permissions' => json_encode($preset['permissions']),
|
||||
'is_system' => $preset['is_system'],
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$seeded++;
|
||||
$this->info(" ✓ Seeded role preset '{$preset['name']}'");
|
||||
}
|
||||
}
|
||||
|
||||
if ($seeded === 0) {
|
||||
$this->warn(' ℹ All role presets already exist, skipping');
|
||||
}
|
||||
}
|
||||
|
||||
private function createOrganizationRolesTable()
|
||||
{
|
||||
$this->info('Creating organization_roles table...');
|
||||
|
||||
try {
|
||||
if (\Schema::hasTable('organization_roles')) {
|
||||
$this->warn(' ℹ organization_roles table already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
\Schema::create('organization_roles', function (\Illuminate\Database\Schema\Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('label');
|
||||
$table->json('permissions');
|
||||
$table->boolean('is_system')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
$managerPermissions = ['org pay', 'org transactions', 'org reports', 'org contacts', 'org calls', 'org messenger', 'org edit-profile', 'org settings', 'org change-password'];
|
||||
$coordinatorPermissions = ['org transactions', 'org reports', 'org contacts', 'org calls', 'org messenger', 'org edit-profile', 'org settings'];
|
||||
|
||||
DB::table('organization_roles')->insert([
|
||||
[
|
||||
'name' => 'organization-manager',
|
||||
'label' => 'Organization Manager',
|
||||
'permissions' => json_encode($managerPermissions),
|
||||
'is_system' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'organization-coordinator',
|
||||
'label' => 'Organization Coordinator',
|
||||
'permissions' => json_encode($coordinatorPermissions),
|
||||
'is_system' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'bank-manager',
|
||||
'label' => 'Bank Manager',
|
||||
'permissions' => json_encode($managerPermissions),
|
||||
'is_system' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'bank-coordinator',
|
||||
'label' => 'Bank Coordinator',
|
||||
'permissions' => json_encode($coordinatorPermissions),
|
||||
'is_system' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->info(' ✓ organization_roles table created and seeded');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to create organization_roles table: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function addChangePasswordToManagerRoles()
|
||||
{
|
||||
$this->info('Adding org change-password to manager roles...');
|
||||
|
||||
try {
|
||||
if (!\Schema::hasTable('organization_roles')) {
|
||||
$this->warn(' ℹ organization_roles table not found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
$managers = \App\Models\OrganizationRole::whereIn('name', ['organization-manager', 'bank-manager'])->get();
|
||||
$updated = 0;
|
||||
|
||||
foreach ($managers as $role) {
|
||||
$permissions = $role->permissions ?? [];
|
||||
if (!in_array('org change-password', $permissions)) {
|
||||
$permissions[] = 'org change-password';
|
||||
$role->permissions = $permissions;
|
||||
$role->save();
|
||||
$this->info(" ✓ Added org change-password to '{$role->name}'");
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated === 0) {
|
||||
$this->warn(' ℹ Manager roles already have org change-password, skipping');
|
||||
}
|
||||
|
||||
// Re-sync scoped Spatie roles so the permission takes effect
|
||||
if ($updated > 0) {
|
||||
$allSpatieRoles = \Spatie\Permission\Models\Role::where('guard_name', 'web')->get();
|
||||
foreach ($managers->fresh() as $orgRole) {
|
||||
$suffix = '\\' . $orgRole->name;
|
||||
$spatieRoles = $allSpatieRoles->filter(fn($r) => str_ends_with($r->name, $suffix));
|
||||
foreach ($spatieRoles as $spatieRole) {
|
||||
$spatieRole->syncPermissions($orgRole->permissions ?? []);
|
||||
$this->info(" ✓ Synced '{$spatieRole->name}'");
|
||||
}
|
||||
}
|
||||
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to add org change-password to manager roles: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function fixCoordinatorSystemRoles()
|
||||
{
|
||||
$this->info('Fixing coordinator roles is_system flag...');
|
||||
|
||||
try {
|
||||
if (!\Schema::hasTable('organization_roles')) {
|
||||
$this->warn(' ℹ organization_roles table not found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
$updated = DB::table('organization_roles')
|
||||
->whereIn('name', ['organization-coordinator', 'bank-coordinator'])
|
||||
->where('is_system', true)
|
||||
->update(['is_system' => false]);
|
||||
|
||||
if ($updated > 0) {
|
||||
$this->info(" ✓ Set {$updated} coordinator role(s) to is_system=false");
|
||||
} else {
|
||||
$this->warn(' ℹ Coordinator roles already correct, skipping');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to fix coordinator roles: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function addPasswordChangeLockColumns()
|
||||
{
|
||||
$this->info('Adding password_change_disabled column...');
|
||||
|
||||
try {
|
||||
foreach (['users', 'admins', 'banks'] as $tableName) {
|
||||
if (\Schema::hasColumn($tableName, 'password_change_disabled')) {
|
||||
$this->warn(" ℹ {$tableName}.password_change_disabled already exists, skipping");
|
||||
continue;
|
||||
}
|
||||
\Schema::table($tableName, function (\Illuminate\Database\Schema\Blueprint $table) {
|
||||
$table->boolean('password_change_disabled')->default(false)->after('password');
|
||||
});
|
||||
$this->info(" ✓ Added {$tableName}.password_change_disabled");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to add password_change_disabled: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function migrateHardcodedOrgRolesToOrganizationRoles()
|
||||
{
|
||||
$this->info('Syncing scoped org/bank Spatie roles from OrganizationRole definitions...');
|
||||
|
||||
if (!\Schema::hasTable('organization_roles')) {
|
||||
$this->warn(' ℹ organization_roles table not found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$orgRoles = \App\Models\OrganizationRole::all();
|
||||
$allSpatieRoles = Role::where('guard_name', 'web')->get();
|
||||
$synced = 0;
|
||||
|
||||
foreach ($orgRoles as $orgRole) {
|
||||
$suffix = '\\' . $orgRole->name;
|
||||
$spatieRoles = $allSpatieRoles->filter(fn($r) => str_ends_with($r->name, $suffix));
|
||||
foreach ($spatieRoles as $spatieRole) {
|
||||
$spatieRole->syncPermissions($orgRole->permissions ?? []);
|
||||
$this->info(" ✓ Synced '{$spatieRole->name}'");
|
||||
$synced++;
|
||||
}
|
||||
}
|
||||
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
$this->info(" ✓ Synced {$synced} scoped org/bank role(s)");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to sync org/bank roles: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function createAdminRolesTable()
|
||||
{
|
||||
$this->info('Creating admin_roles table...');
|
||||
|
||||
try {
|
||||
if (\Schema::hasTable('admin_roles')) {
|
||||
$this->warn(' ℹ admin_roles table already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
\Schema::create('admin_roles', function (\Illuminate\Database\Schema\Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('label');
|
||||
$table->json('permissions');
|
||||
$table->boolean('is_system')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
DB::table('admin_roles')->insert([
|
||||
[
|
||||
'name' => 'administrator',
|
||||
'label' => 'Administrator',
|
||||
'permissions' => json_encode([
|
||||
'manage posts', 'manage tags', 'manage categories',
|
||||
'manage mailings', 'manage calls', 'manage users',
|
||||
'manage user_projects', 'manage organizations', 'manage banks',
|
||||
'manage admins', 'manage accounts',
|
||||
'view profiles', 'create profiles', 'update profiles', 'delete profiles', 'manage profiles',
|
||||
'view permissions', 'create permissions', 'update permissions', 'delete permissions', 'manage permissions',
|
||||
'view roles', 'create roles', 'update roles', 'delete roles', 'manage roles',
|
||||
'manage maintenance',
|
||||
'view search-metrics', 'view application-log', 'view inactive-profiles-log', 'view search-reindex-log',
|
||||
'view stats',
|
||||
]),
|
||||
'is_system' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'site-editor',
|
||||
'label' => 'Site Editor',
|
||||
'permissions' => json_encode([
|
||||
'manage posts', 'manage tags', 'manage categories', 'manage calls',
|
||||
]),
|
||||
'is_system' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->info(' ✓ admin_roles table created and seeded');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to create admin_roles table: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll permissions (view / create / update / export / delete / manage polls), separate from posts.
|
||||
* 'manage polls' goes to every admin role type (admin_roles) and to every existing scoped
|
||||
* admin role, including older ones without an admin_roles row. Safe to run again.
|
||||
*/
|
||||
private function addPollPermissions()
|
||||
{
|
||||
$this->info('Adding poll permissions...');
|
||||
|
||||
$permissions = ['view polls', 'create polls', 'update polls', 'export polls', 'delete polls', 'manage polls'];
|
||||
|
||||
try {
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
|
||||
$created = 0;
|
||||
foreach ($permissions as $name) {
|
||||
if (Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web'])->wasRecentlyCreated) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
$types = 0;
|
||||
foreach (\App\Models\AdminRole::all() as $adminRole) {
|
||||
$current = $adminRole->permissions ?? [];
|
||||
if (!in_array('manage polls', $current, true)) {
|
||||
$adminRole->permissions = array_values(array_merge($current, ['manage polls']));
|
||||
$adminRole->save();
|
||||
$types++;
|
||||
}
|
||||
}
|
||||
|
||||
// Added to the scoped roles rather than re-synced, so their other permissions stay as they are
|
||||
$roles = 0;
|
||||
Role::where('guard_name', 'web')->where('name', 'like', 'Admin\\\\%')->get()
|
||||
->each(function (Role $role) use (&$roles) {
|
||||
if (!$role->hasPermissionTo('manage polls')) {
|
||||
$role->givePermissionTo('manage polls');
|
||||
$roles++;
|
||||
}
|
||||
});
|
||||
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
$this->info(" ✓ Added {$created} poll permission(s); 'manage polls' given to {$types} admin role type(s) and {$roles} admin role(s)");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to add poll permissions: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function addAdminCapabilityPermissions()
|
||||
{
|
||||
$this->info('Adding admin profile capability permissions...');
|
||||
|
||||
$caps = ['admin reports', 'admin contacts', 'admin messenger', 'admin settings', 'admin change-password'];
|
||||
|
||||
try {
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
|
||||
$created = 0;
|
||||
foreach ($caps as $name) {
|
||||
$permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
if ($permission->wasRecentlyCreated) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
// Grant all admin caps to the administrator AdminRole and sync its scoped Spatie roles
|
||||
$adminRole = \App\Models\AdminRole::where('name', 'administrator')->first();
|
||||
if ($adminRole) {
|
||||
$current = $adminRole->permissions ?? [];
|
||||
$merged = array_values(array_unique(array_merge($current, $caps)));
|
||||
$adminRole->permissions = $merged;
|
||||
$adminRole->save();
|
||||
|
||||
$suffix = '\\administrator';
|
||||
Role::where('guard_name', 'web')->get()
|
||||
->filter(fn($r) => str_ends_with($r->name, $suffix))
|
||||
->each(fn($r) => $r->syncPermissions($adminRole->permissions));
|
||||
|
||||
$this->info(' ✓ Synced admin capabilities to administrator role');
|
||||
}
|
||||
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
$this->info(" ✓ Added {$created} new admin capability permission(s)");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to add admin capability permissions: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix the Reversal transaction type's icon.
|
||||
*
|
||||
* The type was originally seeded with icon='receipt-refund'; it was
|
||||
* later changed to icon='arrow-path' (rendered mirrored via a CSS
|
||||
* transform at the two blade render sites) but transaction_types is
|
||||
* immutable for the app's restricted DB user, so any environment that
|
||||
* already inserted the row via the earlier migration needs this
|
||||
* corrected via an elevated-privilege command like this one.
|
||||
*/
|
||||
private function fixReversalTransactionTypeIcon()
|
||||
{
|
||||
$this->info('Fixing Reversal transaction type icon...');
|
||||
|
||||
try {
|
||||
$updated = DB::table('transaction_types')
|
||||
->where('id', 8)
|
||||
->where('name', 'Reversal')
|
||||
->where('icon', '!=', 'arrow-path')
|
||||
->update(['icon' => 'arrow-path']);
|
||||
|
||||
if ($updated > 0) {
|
||||
$this->info(' ✓ Updated Reversal transaction type icon to arrow-path');
|
||||
} else {
|
||||
$this->warn(' ℹ Reversal transaction type icon already correct (or type not found), skipping');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ✗ Failed to fix Reversal transaction type icon: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the "Poll" post category (type App\Models\Poll, not bound to a location:
|
||||
* a poll's own targeting decides who sees it).
|
||||
*/
|
||||
private function addPollCategory()
|
||||
{
|
||||
$this->info('Adding "Poll" post category...');
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$category = \App\Models\Category::firstOrCreate(
|
||||
['type' => \App\Models\Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]
|
||||
);
|
||||
|
||||
$translations = [
|
||||
'en' => ['name' => 'Poll', 'slug' => 'poll'],
|
||||
'nl' => ['name' => 'Peiling', 'slug' => 'peiling'],
|
||||
'de' => ['name' => 'Umfrage', 'slug' => 'umfrage'],
|
||||
'es' => ['name' => 'Encuesta', 'slug' => 'encuesta'],
|
||||
'fr' => ['name' => 'Sondage', 'slug' => 'sondage'],
|
||||
];
|
||||
|
||||
foreach ($translations as $locale => $translation) {
|
||||
\App\Models\CategoryTranslation::updateOrCreate(
|
||||
['category_id' => $category->id, 'locale' => $locale],
|
||||
$translation
|
||||
);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
$this->info(" ✓ \"Poll\" category added/updated (id {$category->id})");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(' ✗ Failed to add poll category: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* Health check every few minutes, reported to healthchecks.io.
|
||||
*
|
||||
* On 2026-10-07 MariaDB was killed by the kernel's OOM killer at 02:09 and the
|
||||
* site served errors for 14 hours before anyone noticed: nothing watched
|
||||
* whether it was up. This checks the services the site needs and reports the
|
||||
* outcome to a healthchecks.io check:
|
||||
*
|
||||
* - all fine: a plain ping;
|
||||
* - something failed: a ping to /fail with what failed, which alerts at once;
|
||||
* - no ping at all (server down, scheduler broken): healthchecks.io alerts
|
||||
* once the check's grace period has passed.
|
||||
*
|
||||
* Every check catches its own errors and has a short timeout, so one dead
|
||||
* service is reported rather than stopping the run. Failures also go to the
|
||||
* Laravel log. Scheduled in app/Console/Kernel.php; config in
|
||||
* `health_monitor.*` (config/timebank_cc.php).
|
||||
*/
|
||||
class HealthCheck extends Command
|
||||
{
|
||||
protected $signature = 'health:check
|
||||
{--dry-run : Print the results without pinging healthchecks.io}';
|
||||
|
||||
protected $description = 'Check database, Redis, Elasticsearch, the public site and disk space, and report to healthchecks.io.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! timebank_config('health_monitor.enabled', true)) {
|
||||
$this->info('Health monitor is disabled (health_monitor.enabled=false).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$checks = [
|
||||
'database' => fn () => $this->checkDatabase(),
|
||||
'redis' => fn () => $this->checkRedis(),
|
||||
'elasticsearch' => fn () => $this->checkElasticsearch(),
|
||||
'website' => fn () => $this->checkWebsite(),
|
||||
'disk' => fn () => $this->checkDisk(),
|
||||
];
|
||||
|
||||
$failures = [];
|
||||
|
||||
foreach ($checks as $name => $check) {
|
||||
try {
|
||||
$problem = $check();
|
||||
} catch (\Throwable $e) {
|
||||
$problem = $e->getMessage();
|
||||
}
|
||||
|
||||
if ($problem === null) {
|
||||
$this->line("OK: {$name}");
|
||||
} else {
|
||||
$failures[] = "{$name}: {$problem}";
|
||||
$this->error("FAIL: {$name}: {$problem}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($failures as $line) {
|
||||
Log::error('[health-monitor] ' . $line);
|
||||
}
|
||||
|
||||
$this->report($failures);
|
||||
|
||||
return empty($failures) ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
/** null when healthy, otherwise what is wrong. */
|
||||
private function checkDatabase(): ?string
|
||||
{
|
||||
DB::select('SELECT 1');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function checkRedis(): ?string
|
||||
{
|
||||
$reply = Redis::connection()->ping();
|
||||
|
||||
// phpredis answers true (or '+PONG'), predis a Status object.
|
||||
return ($reply === true || stripos((string) $reply, 'PONG') !== false)
|
||||
? null
|
||||
: 'unexpected reply to PING: ' . var_export((string) $reply, true);
|
||||
}
|
||||
|
||||
private function checkElasticsearch(): ?string
|
||||
{
|
||||
$host = config('elasticsearch.host');
|
||||
|
||||
if (! $host) {
|
||||
// Not every installation runs search.
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = (str_contains($host, '://') ? $host : 'http://' . $host) . '/_cluster/health';
|
||||
$request = Http::timeout(10);
|
||||
|
||||
if (config('elasticsearch.user')) {
|
||||
$request = $request->withBasicAuth(config('elasticsearch.user'), (string) config('elasticsearch.password'));
|
||||
}
|
||||
|
||||
$response = $request->get($url);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return 'HTTP ' . $response->status();
|
||||
}
|
||||
|
||||
// Yellow is normal on a single node (replicas cannot be placed); red
|
||||
// means data is unavailable.
|
||||
$status = $response->json('status');
|
||||
|
||||
return $status === 'red' ? 'cluster status red' : null;
|
||||
}
|
||||
|
||||
private function checkWebsite(): ?string
|
||||
{
|
||||
$url = timebank_config('health_monitor.website_url') ?: config('app.url');
|
||||
|
||||
// Over the public URL on purpose: this goes through Apache, PHP, the
|
||||
// database and the TLS certificate, as a visitor's request does.
|
||||
$response = Http::timeout(20)->get($url);
|
||||
|
||||
return $response->status() === 200 ? null : "{$url} answered HTTP {$response->status()}";
|
||||
}
|
||||
|
||||
private function checkDisk(): ?string
|
||||
{
|
||||
$path = base_path();
|
||||
$total = @disk_total_space($path);
|
||||
$free = @disk_free_space($path);
|
||||
|
||||
if (! $total || $free === false) {
|
||||
return "cannot read disk space for {$path}";
|
||||
}
|
||||
|
||||
$used = (int) round(100 * (1 - $free / $total));
|
||||
$limit = (int) timebank_config('health_monitor.disk_max_percent', 90);
|
||||
|
||||
// Above 90% Elasticsearch also stops allocating shards.
|
||||
return $used >= $limit ? "{$used}% used (limit {$limit}%)" : null;
|
||||
}
|
||||
|
||||
/** Report to healthchecks.io: a ping when healthy, /fail with the reasons otherwise. */
|
||||
private function report(array $failures): void
|
||||
{
|
||||
$pingUrl = timebank_config('health_monitor.healthcheck_url');
|
||||
|
||||
if (! $pingUrl) {
|
||||
$this->comment('No health_monitor.healthcheck_url set - nothing reported.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->comment('Dry run - not reported to healthchecks.io.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$url = rtrim($pingUrl, '/') . (empty($failures) ? '' : '/fail');
|
||||
$body = empty($failures) ? 'All checks passed.' : implode("\n", $failures);
|
||||
|
||||
try {
|
||||
// healthchecks.io shows the body in its alert, so the email says
|
||||
// what failed.
|
||||
Http::timeout(10)->withBody($body, 'text/plain')->post($url);
|
||||
} catch (\Throwable $e) {
|
||||
// Not reaching healthchecks.io ends as a missed ping, which it
|
||||
// alerts on itself after the grace period.
|
||||
Log::error('[health-monitor] Could not reach healthchecks.io: ' . $e->getMessage());
|
||||
$this->error('Could not reach healthchecks.io: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(strtolower(trim($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', strtolower(trim($email)))
|
||||
->update(['is_suppressed' => false, 'bounce_type' => 'unsuppressed']);
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?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 = (int) (now()->diffInDays($lastLoginAt, true));
|
||||
|
||||
// Set inactive_at to current timestamp and reset warning tracker
|
||||
$profile->inactive_at = now();
|
||||
$profile->last_inactive_warning = null;
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Keeps the incomplete_at timestamp in step with the live completeness rule.
|
||||
*
|
||||
* ProfileTrait::hasIncompleteProfile() answers "is this profile incomplete
|
||||
* now?" from its fields and relations. That is enough to label a profile page,
|
||||
* but it leaves no history — the statistics page could only ever show a flat
|
||||
* present-day count. This command records the state so it can be charted over
|
||||
* time, mirroring profiles:mark-inactive.
|
||||
*
|
||||
* Both directions matter: a profile that gets filled in has its timestamp
|
||||
* cleared, so the figure tracks the current population rather than a
|
||||
* ratchet that only ever climbs.
|
||||
*/
|
||||
class MarkIncompleteProfiles extends Command
|
||||
{
|
||||
protected $signature = 'profiles:mark-incomplete
|
||||
{--dry-run : Report what would change without writing}';
|
||||
|
||||
protected $description = 'Record which profiles are incomplete, so the state can be reported over time';
|
||||
|
||||
/** Banks carry the column for consistency but are not part of the metric. */
|
||||
private const TYPES = [User::class, Organization::class];
|
||||
|
||||
protected string $logFile;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->logFile = storage_path('logs/mark-incomplete-profiles.log');
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$config = timebank_config('profile_incomplete');
|
||||
|
||||
if (empty($config)) {
|
||||
$this->warn('No profile_incomplete configuration; nothing to do.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$relations = array_values(array_filter(
|
||||
$config['check_relations'] ?? [],
|
||||
fn($relation) => is_string($relation) && $relation !== ''
|
||||
));
|
||||
|
||||
$marked = 0;
|
||||
$cleared = 0;
|
||||
|
||||
foreach (self::TYPES as $type) {
|
||||
if (!method_exists($type, 'hasIncompleteProfile')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only relations the type actually defines — a white-label config
|
||||
// may name one that does not apply to every profile type.
|
||||
$usable = array_values(array_filter(
|
||||
$relations,
|
||||
fn($relation) => method_exists($type, $relation)
|
||||
));
|
||||
|
||||
$type::query()
|
||||
->notRemoved()
|
||||
->with($usable)
|
||||
->chunkById(500, function ($profiles) use (&$marked, &$cleared, $dryRun) {
|
||||
foreach ($profiles as $profile) {
|
||||
$isIncomplete = $profile->hasIncompleteProfile($profile);
|
||||
$wasIncomplete = !empty($profile->incomplete_at);
|
||||
|
||||
if ($isIncomplete === $wasIncomplete) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isIncomplete) {
|
||||
$marked++;
|
||||
} else {
|
||||
$cleared++;
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
// Keep the original timestamp if it is already set:
|
||||
// this records when the profile became incomplete,
|
||||
// not when the command last looked at it.
|
||||
$profile->incomplete_at = $isIncomplete ? now() : null;
|
||||
$profile->saveQuietly();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$summary = $dryRun
|
||||
? "Dry run: {$marked} would be marked incomplete, {$cleared} would be cleared"
|
||||
: "Marked {$marked} profiles incomplete, cleared {$cleared}";
|
||||
|
||||
$this->info($summary);
|
||||
$this->logMessage($summary);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function logMessage(string $message): void
|
||||
{
|
||||
try {
|
||||
file_put_contents(
|
||||
$this->logFile,
|
||||
'[' . now()->toDateTimeString() . '] ' . $message . PHP_EOL,
|
||||
FILE_APPEND
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('MarkIncompleteProfiles: could not write log file', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
<?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} {--force : Skip confirmation prompts (used when called programmatically)}';
|
||||
|
||||
/**
|
||||
* 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');
|
||||
$force = $this->option('force');
|
||||
|
||||
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 && !$force && !$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 && !$force && !$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', 'last_inactive_warning', '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', 'last_inactive_warning', '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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
<?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} {--force : Skip confirmation prompts (used when called programmatically)}';
|
||||
|
||||
/**
|
||||
* 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');
|
||||
$force = $this->option('force');
|
||||
|
||||
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 && !$force && !$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 && !$force && !$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 || preg_match('/^' . preg_quote($userAccountName, '/') . ' \d+$/', $account->name)) {
|
||||
// Rename 'personal' or 'personal 2', etc. 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' || preg_match('/^gift \d+$/', $account->name)) {
|
||||
// Rename 'gift' or 'gift 2', etc. 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?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')
|
||||
->whereNotNull('email_verified_at')
|
||||
->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\PlatformOverrides;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Read-only report on the platform folder: which config is used, whether its public files are served, and
|
||||
* overrides that went stale (translation keys the code no longer has, templates whose original changed).
|
||||
* Exit code 1 when something is broken (e.g. invalid JSON); warnings alone exit 0.
|
||||
*/
|
||||
class PlatformCheck extends Command
|
||||
{
|
||||
protected $signature = 'platform:check';
|
||||
|
||||
protected $description = 'Report on the platform folder and overrides that went stale (read-only)';
|
||||
|
||||
public function handle(PlatformOverrides $overrides): int
|
||||
{
|
||||
$marks = ['ok' => '<info> ✓</info>', 'info' => '<comment> i</comment>', 'warn' => '<comment> !</comment>', 'fail' => '<error> ✗</error>'];
|
||||
$findings = $overrides->check();
|
||||
|
||||
$this->line('Platform folder: ' . platform_path());
|
||||
foreach ($findings as $finding) {
|
||||
$this->line("{$marks[$finding['level']]} {$finding['message']}");
|
||||
}
|
||||
|
||||
$count = fn (string $level) => count(array_filter($findings, fn ($f) => $f['level'] === $level));
|
||||
$this->newLine();
|
||||
$this->line("{$count('warn')} warnings, {$count('fail')} failed");
|
||||
|
||||
return $count('fail') ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Create the platform folder from platform.example/. With --from-current, also copy what this installation runs on
|
||||
* today: config/<TIMEBANK_CONFIG>.php to config.php, the active theme to theme.php. Never overwrites a file; the old
|
||||
* files stay in place (platform:check warns while both exist).
|
||||
*/
|
||||
class PlatformInit extends Command
|
||||
{
|
||||
protected $signature = 'platform:init {--from-current : Copy the active platform config and theme into the folder}';
|
||||
|
||||
protected $description = 'Create the platform folder (optionally from the current config and theme)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$root = platform_path();
|
||||
$skeleton = base_path('platform.example');
|
||||
|
||||
foreach (File::allFiles($skeleton, true) as $file) {
|
||||
$this->copyIfMissing($file->getPathname(), "{$root}/{$file->getRelativePathname()}");
|
||||
}
|
||||
|
||||
if ($this->option('from-current')) {
|
||||
$name = (string) config('app.timebank_config');
|
||||
if (is_file(config_path("{$name}.php"))) {
|
||||
$this->copyIfMissing(config_path("{$name}.php"), "{$root}/config.php");
|
||||
} else {
|
||||
$this->warn("No config/{$name}.php to copy.");
|
||||
}
|
||||
|
||||
$id = theme_id();
|
||||
$theme = ['id' => $id, ...(config("themes.themes.{$id}") ?? [])];
|
||||
$this->writeIfMissing("{$root}/theme.php", "<?php\n\n// The theme of this platform, copied from config/themes.php ({$id}) by platform:init.\nreturn " . $this->export($theme) . ";\n");
|
||||
|
||||
$this->newLine();
|
||||
$this->line('Brand files to copy by hand, if this platform has them:');
|
||||
// The files in use now, after the fallbacks (a theme's own logo, the generated text logo, the default)
|
||||
foreach ([false => 'mail-logo.png', true => 'mail-logo-inverted.png'] as $inverted => $target) {
|
||||
$logo = theme_email_logo_file((bool) $inverted);
|
||||
if (is_file($logo)) {
|
||||
$this->line(' ' . (str_starts_with($logo, base_path() . '/') ? substr($logo, strlen(base_path()) + 1) : $logo) . " -> platform/public/{$target}");
|
||||
}
|
||||
}
|
||||
if ($dir = theme_logo('favicon_dir')) {
|
||||
$this->line(" public/{$dir}/* -> platform/public/");
|
||||
}
|
||||
$this->line(' a changed logo: php artisan platform:override ' . (theme_logo('svg_inline') ?: "logos.{$id}"));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Platform folder: {$root}. Then: php artisan platform:link, php artisan platform:check.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function copyIfMissing(string $from, string $to): void
|
||||
{
|
||||
if (file_exists($to)) {
|
||||
$this->line(" kept {$to}");
|
||||
|
||||
return;
|
||||
}
|
||||
File::ensureDirectoryExists(dirname($to));
|
||||
File::copy($from, $to);
|
||||
$this->line(" created {$to}");
|
||||
}
|
||||
|
||||
private function writeIfMissing(string $to, string $content): void
|
||||
{
|
||||
if (file_exists($to)) {
|
||||
$this->line(" kept {$to}");
|
||||
|
||||
return;
|
||||
}
|
||||
File::ensureDirectoryExists(dirname($to));
|
||||
File::put($to, $content);
|
||||
$this->line(" created {$to}");
|
||||
}
|
||||
|
||||
/** An array as PHP source with short array syntax, indented like the config files. */
|
||||
private function export(mixed $value, int $depth = 0): string
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return var_export($value, true);
|
||||
}
|
||||
|
||||
$pad = str_repeat(' ', $depth + 1);
|
||||
$isList = array_is_list($value);
|
||||
$lines = array_map(
|
||||
fn ($key, $item) => $pad . ($isList ? '' : var_export($key, true) . ' => ') . $this->export($item, $depth + 1) . ',',
|
||||
array_keys($value),
|
||||
$value
|
||||
);
|
||||
|
||||
return $lines ? "[\n" . implode("\n", $lines) . "\n" . str_repeat(' ', $depth) . ']' : '[]';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Serve the platform folder's public/ at /platform: the link public/platform -> platform_path('public').
|
||||
* Like storage:link. deploy.sh runs it; running it again changes nothing.
|
||||
*/
|
||||
class PlatformLink extends Command
|
||||
{
|
||||
protected $signature = 'platform:link';
|
||||
|
||||
protected $description = 'Link public/platform to the platform folder\'s public/ (favicons, logos)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$link = public_path('platform');
|
||||
$target = platform_path('public');
|
||||
|
||||
if (!is_dir($target)) {
|
||||
if (is_link($link)) {
|
||||
unlink($link);
|
||||
$this->info("Removed public/platform: {$target} does not exist.");
|
||||
} else {
|
||||
$this->line("No platform public files ({$target}): nothing to link.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (is_link($link)) {
|
||||
if (readlink($link) === $target) {
|
||||
$this->line("public/platform already links to {$target}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
unlink($link);
|
||||
} elseif (file_exists($link)) {
|
||||
$this->error('public/platform exists and is not a link: move it away first.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
symlink($target, $link);
|
||||
$this->info("Linked public/platform to {$target}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\PlatformOverrides;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Copy a template from the code into the platform folder to change it there. The copy starts with a Blade
|
||||
* comment holding the original's sha1, so platform:check can tell when the original changes later.
|
||||
*
|
||||
* php artisan platform:override emails.layouts.html an app view
|
||||
* php artisan platform:override mail::html.panel a mail component (header, footer, button, ...)
|
||||
* php artisan platform:override wirechat::components.divider a package view
|
||||
*/
|
||||
class PlatformOverride extends Command
|
||||
{
|
||||
protected $signature = 'platform:override {view : View name, e.g. emails.layouts.html, mail::html.panel or wirechat::components.divider}
|
||||
{--force : Replace an existing copy (e.g. after the original changed)}';
|
||||
|
||||
protected $description = 'Copy a template into the platform folder, with a fingerprint of its original';
|
||||
|
||||
public function handle(PlatformOverrides $overrides): int
|
||||
{
|
||||
$view = (string) $this->argument('view');
|
||||
$namespace = str_contains($view, '::') ? strstr($view, '::', true) : null;
|
||||
$name = str_replace('.', '/', $namespace ? substr($view, strlen($namespace) + 2) : $view) . '.blade.php';
|
||||
$relative = match ($namespace) {
|
||||
null => $name,
|
||||
'mail' => "mail/{$name}",
|
||||
default => "vendor/{$namespace}/{$name}",
|
||||
};
|
||||
|
||||
$original = $overrides->originalOf($relative);
|
||||
if ($original === null) {
|
||||
$this->error("No template {$view} in the code.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$target = platform_path("views/{$relative}");
|
||||
if (is_file($target) && !$this->option('force')) {
|
||||
$this->error("platform/views/{$relative} exists already. Use --force to replace it (your changes in it are lost).");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists(dirname($target));
|
||||
File::put($target, '{{-- platform:override of ' . $overrides->shown($original) . ' sha1:' . sha1_file($original) . " --}}\n" . File::get($original));
|
||||
$this->info("Copied {$overrides->shown($original)} to platform/views/{$relative}. Change it there; php artisan view:clear afterwards.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
<?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;
|
||||
$failed = 0;
|
||||
foreach ($emails as $email) {
|
||||
$bounceInfo = $this->parseBounceEmail($email);
|
||||
|
||||
if ($bounceInfo) {
|
||||
$this->line("Processing bounce for: {$bounceInfo['email']} ({$bounceInfo['type']})");
|
||||
|
||||
if (!$dryRun) {
|
||||
// One message that fails is logged and left in the
|
||||
// mailbox; it must not stop the ones after it.
|
||||
try {
|
||||
MailingBounce::recordBounce(
|
||||
$bounceInfo['email'],
|
||||
$bounceInfo['type'],
|
||||
$bounceInfo['reason'],
|
||||
$bounceInfo['mailing_id'] ?? null,
|
||||
$bounceInfo['bounced_at'] ?? null
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$failed++;
|
||||
$this->error("Could not record bounce email ID {$email['id']}: " . $e->getMessage());
|
||||
Log::error("Bounce processing error (message {$email['id']}): " . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
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)' : '') . ($failed ? ", {$failed} failed" : ''));
|
||||
$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 ?? '';
|
||||
$rawHeaders = imap_fetchheader($connection, $i);
|
||||
|
||||
if ($this->isBounceEmail($subject, $header, $rawHeaders)) {
|
||||
$body = imap_body($connection, $i);
|
||||
$emails[] = [
|
||||
'id' => $i,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'header' => $header,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $emails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email is a genuine SMTP DSN bounce (RFC 3464).
|
||||
*
|
||||
* Requires BOTH:
|
||||
* 1. Content-Type: multipart/report; report-type=delivery-status
|
||||
* 2. Sender is mailer-daemon / postmaster (not a real user address)
|
||||
*
|
||||
* A normal user emailing this mailbox will never produce a
|
||||
* multipart/report message, so this prevents deliberate suppression abuse.
|
||||
*/
|
||||
protected function isBounceEmail(string $subject, $header, string $rawHeaders = ''): bool
|
||||
{
|
||||
// Must be a multipart/report with report-type=delivery-status (RFC 3464)
|
||||
$contentType = strtolower($rawHeaders);
|
||||
$hasReportContentType = str_contains($contentType, 'multipart/report') &&
|
||||
str_contains($contentType, 'report-type=delivery-status');
|
||||
|
||||
if (!$hasReportContentType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sender must be mailer-daemon or postmaster — not a real user
|
||||
$fromMailbox = strtolower($header->from[0]->mailbox ?? '');
|
||||
$fromHost = strtolower($header->from[0]->host ?? '');
|
||||
$fromFull = $fromMailbox . '@' . $fromHost;
|
||||
|
||||
$bounceSenders = ['mailer-daemon', 'postmaster', 'mail-daemon'];
|
||||
foreach ($bounceSenders as $sender) {
|
||||
if (str_contains($fromMailbox, $sender) || str_contains($fromFull, $sender)) {
|
||||
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,
|
||||
'bounced_at' => $this->bounceDate($email['header'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* When the bounce message was sent, from its Date header.
|
||||
* Null (record as now) when the header is missing or unreadable.
|
||||
*/
|
||||
protected function bounceDate($header): ?\Carbon\Carbon
|
||||
{
|
||||
$timestamp = (int) ($header->udate ?? 0);
|
||||
|
||||
if ($timestamp <= 0 || $timestamp > now()->timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return \Carbon\Carbon::createFromTimestamp($timestamp, config('app.timezone'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
// Custom header quoted in DSN body (e.g. "X-Mailing-ID: 42")
|
||||
if (preg_match('/x-mailing-id[:\s]*(\d+)/i', $body, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
// Generic key-value form (e.g. "mailing_id: 42")
|
||||
if (preg_match('/mailing[_-]?id[:\s]*(\d+)/i', $body, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
// Footer reference embedded in the original email body (fallback)
|
||||
if (preg_match('/mailing\s+#(\d+)/i', $body, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete processed email
|
||||
*/
|
||||
protected function deleteEmail($connection, int $messageId, array $config): void
|
||||
{
|
||||
// Only marked here; closeConnection() expunges once at the end.
|
||||
// Expunging after each delete renumbered the remaining messages, so
|
||||
// the next delete hit the wrong one.
|
||||
if ($config['protocol'] === 'imap') {
|
||||
imap_delete($connection, $messageId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close connection
|
||||
*/
|
||||
protected function closeConnection($connection, array $config): void
|
||||
{
|
||||
if ($config['protocol'] === 'imap') {
|
||||
imap_expunge($connection);
|
||||
imap_close($connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
<?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 = (int) (now()->diffInSeconds($profile->inactive_at, true));
|
||||
$secondsRemaining = $this->thresholds['run_delete'] - $secondsSinceInactive;
|
||||
$lastWarning = $profile->last_inactive_warning;
|
||||
|
||||
// Determine action based on thresholds, skipping levels already sent
|
||||
if ($secondsSinceInactive >= $this->thresholds['run_delete']) {
|
||||
// Delete profile
|
||||
return $this->deleteProfile($profile, $profileType, $secondsSinceInactive);
|
||||
} elseif ($secondsSinceInactive >= $this->thresholds['warning_final'] && $lastWarning !== 'final') {
|
||||
// Send final warning (only once)
|
||||
return $this->sendWarning($profile, $profileType, 'final', $secondsRemaining, $secondsSinceInactive);
|
||||
} elseif ($secondsSinceInactive >= $this->thresholds['warning_2'] && $secondsSinceInactive < $this->thresholds['warning_final'] && $lastWarning !== 'warning_2' && $lastWarning !== 'final') {
|
||||
// Send warning 2 (only once)
|
||||
return $this->sendWarning($profile, $profileType, 'warning_2', $secondsRemaining, $secondsSinceInactive);
|
||||
} elseif ($secondsSinceInactive >= $this->thresholds['warning_1'] && $secondsSinceInactive < $this->thresholds['warning_2'] && $lastWarning === null) {
|
||||
// Send warning 1 (only once)
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
// Record which warning level was last sent so it is not repeated
|
||||
$profile->last_inactive_warning = $warningLevel;
|
||||
$profile->save();
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = (int) (now()->diffInDays($expiresAt, false));
|
||||
if ($daysRemaining > 0) {
|
||||
$timeRemaining = $daysRemaining . ' day' . ($daysRemaining > 1 ? 's' : '');
|
||||
} else {
|
||||
$hoursRemaining = (int) (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 = (int) (now()->diffInDays($expiresAt, false));
|
||||
if ($daysRemaining > 0) {
|
||||
$timeRemaining = $daysRemaining . 'd';
|
||||
} else {
|
||||
$hoursRemaining = (int) (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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
<?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,
|
||||
'polls_created' => 0,
|
||||
'polls_protected' => 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 === 'overwrite' && \App\Polls\PollBackup::protectsSlugs($existingSlugs)) {
|
||||
// Overwriting would delete a poll's votes and ballot ledger (cascade): keep the existing post
|
||||
$this->warn('Not overwritten: the existing post has a poll with votes.');
|
||||
$stats['posts_skipped']++;
|
||||
$stats['polls_protected']++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
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']++;
|
||||
}
|
||||
|
||||
// Poll definition: restored as a draft, without votes
|
||||
if (!empty($postData['poll'])) {
|
||||
\App\Polls\PollBackup::import($post, $postData['poll']);
|
||||
$stats['polls_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;
|
||||
$stats['polls_created'] += !empty($postData['poll']) ? 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']],
|
||||
['Polls Created (as draft)', $stats['polls_created']],
|
||||
['Posts With Votes Not Overwritten', $stats['polls_protected']],
|
||||
['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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Poll;
|
||||
use App\Polls\Ledger;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SealClosedPolls extends Command
|
||||
{
|
||||
protected $signature = 'polls:seal';
|
||||
|
||||
protected $description = 'Seal the ballot ledger of polls that have closed (status closed or past their closing time)';
|
||||
|
||||
public function handle(Ledger $ledger): int
|
||||
{
|
||||
$polls = Poll::whereNull('sealed_at')
|
||||
->where(fn ($query) => $query->where('status', Poll::STATUS_CLOSED)
|
||||
->orWhere(fn ($q) => $q->where('status', Poll::STATUS_OPEN)->where('closes_at', '<=', now())))
|
||||
->get();
|
||||
|
||||
foreach ($polls as $poll) {
|
||||
if ($entry = $ledger->seal($poll)) {
|
||||
$this->info("Sealed poll {$poll->id}: {$entry->hash}");
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?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';
|
||||
|
||||
/**
|
||||
* Every log written by the scheduler through appendOutputTo().
|
||||
*
|
||||
* appendOutputTo() never rotates - it appends for the life of the server.
|
||||
* Laravel's own daily driver rotates laravel-*.log, but these files sit
|
||||
* outside it, so without this command they grow without bound. On
|
||||
* production wirechat-cleanup.log had reached 4.4 MB from a task that runs
|
||||
* every five minutes, and bounce-processing.log 1.8 MB.
|
||||
*
|
||||
* Keep in step with the appendOutputTo() calls in Console\Kernel.
|
||||
*/
|
||||
/**
|
||||
* Lines kept in a log that carries no timestamps, so it can still be
|
||||
* capped. Roughly a fortnight of a five-minute task at two lines a run.
|
||||
*/
|
||||
private const MAX_UNDATED_LINES = 10000;
|
||||
|
||||
protected $logFiles = [
|
||||
'inactive-profiles.log',
|
||||
'mark-inactive-profiles.log',
|
||||
'mark-incomplete-profiles.log',
|
||||
'permanent-deletions.log',
|
||||
'wirechat-cleanup.log',
|
||||
'bounce-processing.log',
|
||||
'scout-reindex.log',
|
||||
'call-expiry.log',
|
||||
'cert-monitor.log',
|
||||
'ip-cleanup.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)) {
|
||||
// Normal: a task that is disabled or has never run writes no
|
||||
// log. Not worth a warning on every monthly run.
|
||||
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: they are usually a
|
||||
// continuation of the timestamped line above (a stack trace,
|
||||
// wrapped output) and dropping them would corrupt the entry.
|
||||
$keptLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
// Date-based trimming only works on files whose lines carry a
|
||||
// timestamp. appendOutputTo() writes a command's raw stdout, which
|
||||
// mostly does not ("Deleting expired disappearing messages..."), so
|
||||
// those files would keep every line forever. Fall back to a tail: keep
|
||||
// the most recent lines and drop the rest.
|
||||
if ($removedCount === 0 && count($keptLines) > self::MAX_UNDATED_LINES) {
|
||||
$dated = false;
|
||||
foreach ($keptLines as $line) {
|
||||
if (preg_match('/^\[\d{4}-\d{2}-\d{2} /', $line)) {
|
||||
$dated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dated) {
|
||||
$keptLines = array_slice($keptLines, -self::MAX_UNDATED_LINES);
|
||||
File::put($logPath, implode("\n", $keptLines) . "\n");
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($removedCount > 0) {
|
||||
// Rewrite the log file with only kept lines
|
||||
File::put($logPath, implode("\n", $keptLines) . "\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in human-readable format.
|
||||
*
|
||||
* @param int $bytes
|
||||
* @return string
|
||||
*/
|
||||
protected function formatFileSize($bytes)
|
||||
{
|
||||
if ($bytes >= 1048576) {
|
||||
return round($bytes / 1048576, 2) . ' MB';
|
||||
} elseif ($bytes >= 1024) {
|
||||
return round($bytes / 1024, 2) . ' KB';
|
||||
} else {
|
||||
return $bytes . ' bytes';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Namu\WireChat\Models\Conversation;
|
||||
|
||||
class UpdateExistingConversationsDisappearing extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'wirechat:update-conversations-disappearing';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Set disappearing_started_at for existing conversations without this field';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
if (!timebank_config('wirechat.disappearing_messages.enabled', true)) {
|
||||
$this->info('Disappearing messages feature is disabled');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Updating existing conversations...');
|
||||
|
||||
// Find all conversations without disappearing_started_at or disappearing_duration set
|
||||
$conversations = Conversation::where(function($query) {
|
||||
$query->whereNull('disappearing_started_at')
|
||||
->orWhereNull('disappearing_duration');
|
||||
})->get();
|
||||
|
||||
if ($conversations->isEmpty()) {
|
||||
$this->info('No conversations need updating');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Get duration in days from config and convert to seconds
|
||||
$durationInDays = timebank_config('wirechat.disappearing_messages.duration', 30);
|
||||
$duration = $durationInDays * 86400; // Convert days to seconds
|
||||
|
||||
$count = 0;
|
||||
foreach ($conversations as $conversation) {
|
||||
$conversation->disappearing_started_at = now();
|
||||
$conversation->disappearing_duration = $duration;
|
||||
$conversation->save();
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->info("Updated {$count} conversations with duration {$duration} seconds");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationRole;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
/**
|
||||
* Read-only check of the areas an upgrade can break, per installation.
|
||||
*
|
||||
* Run it before and after every upgrade step, locally and on each server (references/plans/LARAVEL_UPGRADE_PLAN.md,
|
||||
* section 4.2). It changes nothing: it reads configuration, the database, Elasticsearch and the queues, and opens
|
||||
* one connection to test Reverb. Exit code 1 when a check fails; warnings do not fail.
|
||||
*/
|
||||
class UpgradeVerify extends Command
|
||||
{
|
||||
protected $signature = 'upgrade:verify {--only= : Comma-separated areas: framework, profiles, permissions, white-label, platform, payments, search, background}';
|
||||
|
||||
protected $description = 'Read-only check of profiles, permissions, white-label settings, payments, search and background services';
|
||||
|
||||
/** Tables the application's database user may not UPDATE or DELETE (as in deploy.sh). */
|
||||
public const IMMUTABLE_TABLES = ['transactions', 'transaction_types', 'poll_ledger'];
|
||||
|
||||
public const QUEUES = ['high', 'messages', 'default', 'emails', 'mailing', 'low'];
|
||||
|
||||
/** @var array<int, array{string, string, string}> area, status (ok|warn|fail), message */
|
||||
protected array $results = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$areas = [
|
||||
'framework' => fn () => $this->checkFramework(),
|
||||
'profiles' => fn () => $this->checkProfiles(),
|
||||
'permissions' => fn () => $this->checkPermissions(),
|
||||
'white-label' => fn () => $this->checkWhiteLabel(),
|
||||
'platform' => fn () => $this->checkPlatform(),
|
||||
'payments' => fn () => $this->checkPayments(),
|
||||
'search' => fn () => $this->checkSearch(),
|
||||
'background' => fn () => $this->checkBackground(),
|
||||
];
|
||||
|
||||
$only = array_filter(array_map('trim', explode(',', (string) $this->option('only'))));
|
||||
foreach ($areas as $area => $check) {
|
||||
if ($only && !in_array($area, $only, true)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$check();
|
||||
} catch (\Throwable $e) {
|
||||
$this->fail_($area, 'check crashed: ' . get_class($e) . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$symbols = ['ok' => '<fg=green>✓</>', 'warn' => '<fg=yellow>!</>', 'fail' => '<fg=red>✗</>'];
|
||||
$current = null;
|
||||
foreach ($this->results as [$area, $status, $message]) {
|
||||
if ($area !== $current) {
|
||||
$this->newLine();
|
||||
$this->line("<options=bold>{$area}</>");
|
||||
$current = $area;
|
||||
}
|
||||
$this->line(" {$symbols[$status]} {$message}");
|
||||
}
|
||||
|
||||
$counts = array_count_values(array_column($this->results, 1));
|
||||
$this->newLine();
|
||||
$this->line(sprintf('%d ok, %d warnings, %d failed', $counts['ok'] ?? 0, $counts['warn'] ?? 0, $counts['fail'] ?? 0));
|
||||
|
||||
return ($counts['fail'] ?? 0) > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function ok(string $area, string $message): void
|
||||
{
|
||||
$this->results[] = [$area, 'ok', $message];
|
||||
}
|
||||
|
||||
protected function warn_(string $area, string $message): void
|
||||
{
|
||||
$this->results[] = [$area, 'warn', $message];
|
||||
}
|
||||
|
||||
protected function fail_(string $area, string $message): void
|
||||
{
|
||||
$this->results[] = [$area, 'fail', $message];
|
||||
}
|
||||
|
||||
protected function checkFramework(): void
|
||||
{
|
||||
$this->ok('framework', 'Laravel ' . app()->version() . ', PHP ' . PHP_VERSION);
|
||||
|
||||
Artisan::call('config:merge', ['--all' => true, '--dry-run' => true]);
|
||||
$output = Artisan::output();
|
||||
if (preg_match_all('/Found (\d+) new configuration key/', $output, $m)) {
|
||||
$this->warn_('framework', array_sum($m[1]) . ' new config keys are waiting: run php artisan config:merge --all');
|
||||
} else {
|
||||
$this->ok('framework', 'config files have every key of their templates');
|
||||
}
|
||||
}
|
||||
|
||||
protected function checkProfiles(): void
|
||||
{
|
||||
$guards = array_keys(config('auth.guards', []));
|
||||
$missing = array_diff(['web', 'organization', 'bank', 'admin'], $guards);
|
||||
$missing
|
||||
? $this->fail_('profiles', 'guards missing: ' . implode(', ', $missing))
|
||||
: $this->ok('profiles', 'guards web, organization, bank and admin are configured');
|
||||
|
||||
foreach ([User::class, Organization::class, Bank::class, Admin::class] as $model) {
|
||||
$count = $model::count();
|
||||
$name = class_basename($model);
|
||||
if ($count === 0) {
|
||||
$this->warn_('profiles', "no {$name} profiles in the database");
|
||||
continue;
|
||||
}
|
||||
$model::query()->first()
|
||||
? $this->ok('profiles', "{$name}: {$count} profiles, loading works")
|
||||
: $this->fail_('profiles', "{$name}: could not load a profile");
|
||||
}
|
||||
|
||||
foreach (['getActiveProfile', 'getActiveProfileType', 'timebank_config', 'platform_name', 'theme_email_logo'] as $helper) {
|
||||
if (!function_exists($helper)) {
|
||||
$this->fail_('profiles', "helper {$helper}() is missing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function checkPermissions(): void
|
||||
{
|
||||
// organization_roles is created by `php artisan database:update`, not by a migration
|
||||
$organizationRoles = collect();
|
||||
if (Schema::hasTable('organization_roles')) {
|
||||
$organizationRoles = OrganizationRole::all()->keyBy('name');
|
||||
} else {
|
||||
$this->warn_('permissions', 'table organization_roles is missing: run php artisan database:update');
|
||||
}
|
||||
$definitions = [
|
||||
'Admin' => AdminRole::all()->keyBy('name'),
|
||||
'Organization' => $organizationRoles,
|
||||
'Bank' => $organizationRoles,
|
||||
];
|
||||
|
||||
$scoped = 0;
|
||||
$legacy = [];
|
||||
$mismatched = [];
|
||||
$undefined = [];
|
||||
foreach (Role::with('permissions')->get() as $role) {
|
||||
$parts = explode('\\', $role->name);
|
||||
if (count($parts) !== 3 || !isset($definitions[$parts[0]])) {
|
||||
continue; // base roles without a scope
|
||||
}
|
||||
if (preg_match(AdminRolesMigrate::LEGACY_PATTERN, $role->name)) {
|
||||
$legacy[] = $role->name;
|
||||
continue;
|
||||
}
|
||||
$scoped++;
|
||||
$definition = $definitions[$parts[0]]->get($parts[2]);
|
||||
if (!$definition) {
|
||||
$undefined[] = $role->name;
|
||||
continue;
|
||||
}
|
||||
$expected = collect($definition->permissions ?? [])->sort()->values()->all();
|
||||
$actual = $role->permissions->pluck('name')->sort()->values()->all();
|
||||
if ($expected !== $actual) {
|
||||
$mismatched[] = $role->name;
|
||||
}
|
||||
}
|
||||
|
||||
if ($mismatched) {
|
||||
$this->fail_('permissions', count($mismatched) . " scoped roles differ from their definition: " . implode(', ', array_slice($mismatched, 0, 5))
|
||||
. ' (re-sync: see AGENTS.md, Roles and permissions)');
|
||||
} else {
|
||||
$this->ok('permissions', "{$scoped} scoped roles match admin_roles / organization_roles");
|
||||
}
|
||||
if ($legacy) {
|
||||
$this->warn_('permissions', count($legacy) . ' legacy roles: ' . implode(', ', array_slice($legacy, 0, 5))
|
||||
. ' (replace them: php artisan admin-roles:migrate --dry-run, then without --dry-run)');
|
||||
}
|
||||
if ($undefined) {
|
||||
$this->warn_('permissions', count($undefined) . ' scoped roles have no definition: ' . implode(', ', array_slice($undefined, 0, 5)));
|
||||
}
|
||||
}
|
||||
|
||||
protected function checkWhiteLabel(): void
|
||||
{
|
||||
$name = (string) config('app.timebank_config');
|
||||
// The platform folder's config.php replaces config/<TIMEBANK_CONFIG>.php (PlatformServiceProvider)
|
||||
$file = is_file(platform_path('config.php')) ? 'platform/config.php' : "config/{$name}.php";
|
||||
if ($name === '' || ($file !== 'platform/config.php' && !is_file(config_path("{$name}.php")))) {
|
||||
$this->fail_('white-label', "platform config config/{$name}.php does not exist (TIMEBANK_CONFIG), and there is no platform/config.php");
|
||||
return;
|
||||
}
|
||||
$this->ok('white-label', "platform config: {$file}");
|
||||
|
||||
$platform = platform_name();
|
||||
if ($platform === '' || $platform === 'Example Timebank') {
|
||||
$this->warn_('white-label', "platform name is '{$platform}': set platform_translations in {$file}");
|
||||
} else {
|
||||
$this->ok('white-label', "platform name: {$platform}");
|
||||
}
|
||||
|
||||
theme_color('brand')
|
||||
? $this->ok('white-label', 'theme ' . config('themes.active') . ': brand colour ' . theme_color('brand'))
|
||||
: $this->fail_('white-label', 'theme ' . config('themes.active') . ' has no brand colour');
|
||||
|
||||
foreach (['local_pages', 'android_app'] as $key) {
|
||||
timebank_config($key) === null
|
||||
? $this->warn_('white-label', "setting {$key} is missing: run php artisan config:merge {$name}")
|
||||
: $this->ok('white-label', "setting {$key} is present");
|
||||
}
|
||||
}
|
||||
|
||||
/** The platform folder and its overrides: the same checks as php artisan platform:check. */
|
||||
protected function checkPlatform(): void
|
||||
{
|
||||
foreach (app(\App\Services\PlatformOverrides::class)->check() as $finding) {
|
||||
match ($finding['level']) {
|
||||
'fail' => $this->fail_('platform', $finding['message']),
|
||||
'warn' => $this->warn_('platform', $finding['message']),
|
||||
default => $this->ok('platform', $finding['message']),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected function checkPayments(): void
|
||||
{
|
||||
$database = (string) DB::connection()->getDatabaseName();
|
||||
$grants = collect(DB::select('SHOW GRANTS FOR CURRENT_USER()'))->map(fn ($row) => (string) array_values((array) $row)[0]);
|
||||
|
||||
$allowed = [];
|
||||
foreach ($grants as $grant) {
|
||||
if (!preg_match('/^GRANT (.+?) ON (\S+)\.(\S+) TO /i', $grant, $m)) {
|
||||
continue;
|
||||
}
|
||||
$privileges = strtoupper($m[1]);
|
||||
$db = str_replace(['`', '\\_', '\\%'], ['', '_', '%'], $m[2]);
|
||||
$table = trim($m[3], '`');
|
||||
$dbMatches = $db === '*' || $db === $database || fnmatch(str_replace('%', '*', $db), $database);
|
||||
if (!$dbMatches) {
|
||||
continue;
|
||||
}
|
||||
$writes = str_contains($privileges, 'ALL PRIVILEGES')
|
||||
|| preg_match('/\b(UPDATE|DELETE)\b/', $privileges);
|
||||
if (!$writes) {
|
||||
continue;
|
||||
}
|
||||
foreach (self::IMMUTABLE_TABLES as $immutable) {
|
||||
if ($table === '*' || $table === $immutable) {
|
||||
$allowed[$immutable] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$allowed
|
||||
? $this->fail_('payments', 'the database user ' . DB::selectOne('SELECT CURRENT_USER() AS u')->u
|
||||
. ' may UPDATE or DELETE ' . implode(', ', array_keys($allowed)) . ': financial records are not protected')
|
||||
: $this->ok('payments', 'transactions, transaction_types and poll_ledger cannot be changed or deleted by the app user');
|
||||
}
|
||||
|
||||
protected function checkSearch(): void
|
||||
{
|
||||
$models = ['User', 'Organization', 'Bank', 'Post', 'Call', 'Tag', 'Transaction'];
|
||||
foreach ($models as $model) {
|
||||
$class = "App\\Models\\{$model}";
|
||||
$raw = $class::search('')->take(1)->raw();
|
||||
$total = (int) data_get($raw, 'hits.total.value', data_get($raw, 'hits.total', 0));
|
||||
$index = (new $class)->searchableAs();
|
||||
if ($total > 0) {
|
||||
$this->ok('search', "{$model}: index {$index} answers, " . ($total >= 10000 ? '10000+' : $total) . ' documents');
|
||||
} elseif ($this->hasSearchableRows($class)) {
|
||||
$this->fail_('search', "{$model}: index {$index} is empty while the table has rows to index: re-index (scripts/re-index-search.sh)");
|
||||
} elseif ($class::count() > 0) {
|
||||
$this->ok('search', "{$model}: index {$index} answers (none of the rows may be indexed now, e.g. all expired)");
|
||||
} else {
|
||||
$this->ok('search', "{$model}: index {$index} answers (no rows yet)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any row belongs in the index. Calls and posts index only some rows (shouldBeSearchable(): e.g. not
|
||||
* expired), so an empty index is right when none qualifies; on 2026-10-19 every call on the dev site had expired.
|
||||
*/
|
||||
protected function hasSearchableRows(string $class): bool
|
||||
{
|
||||
if (!method_exists($class, 'shouldBeSearchable') || (new \ReflectionMethod($class, 'shouldBeSearchable'))->getDeclaringClass()->getName() !== $class) {
|
||||
return $class::count() > 0;
|
||||
}
|
||||
|
||||
foreach ($class::query()->lazy() as $row) {
|
||||
if ($row->shouldBeSearchable()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function checkBackground(): void
|
||||
{
|
||||
foreach (self::QUEUES as $queue) {
|
||||
$size = Queue::size($queue);
|
||||
$size > 500
|
||||
? $this->warn_('background', "queue {$queue}: {$size} jobs waiting: are the workers running with --queue=" . implode(',', self::QUEUES) . '?')
|
||||
: $this->ok('background', "queue {$queue}: {$size} jobs waiting");
|
||||
}
|
||||
|
||||
if (config('broadcasting.default') !== 'reverb') {
|
||||
$this->ok('background', 'broadcasting is not Reverb: not checked');
|
||||
return;
|
||||
}
|
||||
$host = (string) config('broadcasting.connections.reverb.options.host', '127.0.0.1');
|
||||
$port = (int) config('broadcasting.connections.reverb.options.port', 8080);
|
||||
$socket = @fsockopen($host, $port, $errno, $error, 2);
|
||||
if ($socket) {
|
||||
fclose($socket);
|
||||
$this->ok('background', "Reverb answers on {$host}:{$port}");
|
||||
} else {
|
||||
$this->fail_('background', "Reverb does not answer on {$host}:{$port} ({$error})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\DeleteExpiredWireChatMessagesJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class WireChatDeleteExpiredMessages extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'wirechat:delete-expired';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete expired disappearing messages from WireChat conversations';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
if (!timebank_config('wirechat.disappearing_messages.enabled', true)) {
|
||||
$this->info('Disappearing messages feature is disabled');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Dispatching job to delete expired disappearing messages...');
|
||||
|
||||
// Dispatch to 'low' queue
|
||||
DeleteExpiredWireChatMessagesJob::dispatch();
|
||||
|
||||
$this->info('Job dispatched to low queue!');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user