Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
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;
}
}
+423
View File
@@ -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,
];
}
}
+413
View File
@@ -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);
}
}
+140
View File
@@ -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>&nbsp;</p>')
->orWhere('about', '"')
->get();
$count = $profiles->count();
if ($count === 0) {
$this->line("✓ {$displayName}: No profiles to clean");
return 0;
}
$bar = $this->output->createProgressBar($count);
$bar->setFormat(" {$displayName}: [%bar%] %current%/%max% (%percent:3s%%)");
foreach ($profiles as $profile) {
$profile->about = null;
$profile->save();
$bar->advance();
}
$bar->finish();
$this->newLine();
return $count;
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Models\Organization;
use App\Models\Bank;
use App\Models\Admin;
use Illuminate\Console\Command;
class CleanCyclosSkills extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'profiles:clean-cyclos-skills';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clean trailing pipe symbols from cyclos_skills field after Cyclos migration';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Starting cleanup of Cyclos migration trailing pipes in cyclos_skills...');
$this->newLine();
$totalCleaned = 0;
// Clean Users
$usersCleaned = $this->cleanProfileSkills(User::class, 'Users');
$totalCleaned += $usersCleaned;
// Clean Organizations
$organizationsCleaned = $this->cleanProfileSkills(Organization::class, 'Organizations');
$totalCleaned += $organizationsCleaned;
// Clean Banks
$banksCleaned = $this->cleanProfileSkills(Bank::class, 'Banks');
$totalCleaned += $banksCleaned;
// Note: Admins table does not have cyclos_skills column
$this->newLine();
$this->info("Cleanup complete! Total profiles cleaned: {$totalCleaned}");
return Command::SUCCESS;
}
/**
* Clean the cyclos_skills field for a specific model type.
*
* @param string $modelClass
* @param string $displayName
* @return int
*/
private function cleanProfileSkills(string $modelClass, string $displayName): int
{
// Find all profiles where cyclos_skills field contains trailing pipes and spaces
$profiles = $modelClass::whereNotNull('cyclos_skills')
->where('cyclos_skills', 'like', '%|%')
->get();
$cleanedCount = 0;
if ($profiles->isEmpty()) {
$this->line("✓ {$displayName}: No profiles to clean");
return 0;
}
$bar = $this->output->createProgressBar($profiles->count());
$bar->setFormat(" {$displayName}: [%bar%] %current%/%max% (%percent:3s%%)");
foreach ($profiles as $profile) {
$original = $profile->cyclos_skills;
// Remove trailing pipes and spaces using regex
// This matches: space, pipe, space, pipe... at the end of the string
$cleaned = preg_replace('/(\s*\|\s*)+$/', '', $original);
// Only update if something changed
if ($cleaned !== $original) {
$profile->cyclos_skills = $cleaned;
$profile->save();
$cleanedCount++;
}
$bar->advance();
}
$bar->finish();
$this->newLine();
return $cleanedCount;
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
namespace App\Console\Commands;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Spatie\Activitylog\Models\Activity;
class CleanupIpAddresses extends Command
{
protected $signature = 'ip:cleanup {--dry-run : Show what would be cleaned without actually deleting}';
protected $description;
public function __construct()
{
parent::__construct();
$this->description = 'Anonymize IP addresses older than ' .
timebank_config('ip_retention.retention_days') . ' days for GDPR compliance';
}
public function handle()
{
$retentionDays = timebank_config('ip_retention.retention_days', 180);
$cutoffDate = now()->subDays($retentionDays);
$isDryRun = $this->option('dry-run');
$this->info('Starting IP address cleanup...');
$this->info('Retention period: ' . $retentionDays . ' days');
$this->info('Cutoff date: ' . $cutoffDate->toDateTimeString());
if ($isDryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
$totalAnonymized = 0;
// Cleanup profile tables (users, organizations, banks, admins)
$profileModels = [
'users' => User::class,
'organizations' => Organization::class,
'banks' => Bank::class,
'admins' => Admin::class,
];
foreach ($profileModels as $tableName => $modelClass) {
$count = $this->cleanupProfileTable($tableName, $modelClass, $cutoffDate, $isDryRun);
$totalAnonymized += $count;
}
// Cleanup activity log IP addresses
$activityLogCount = $this->cleanupActivityLog($cutoffDate, $isDryRun);
$totalAnonymized += $activityLogCount;
$action = $isDryRun ? 'Would anonymize' : 'Anonymized';
$this->info("✓ {$action} {$totalAnonymized} IP address records in total.");
// Log the cleanup action
if (!$isDryRun) {
Log::info('IP address cleanup completed', [
'retention_days' => $retentionDays,
'cutoff_date' => $cutoffDate->toDateTimeString(),
'total_anonymized' => $totalAnonymized,
]);
}
return 0;
}
/**
* Cleanup IP addresses from profile tables
*
* @param string $tableName
* @param string $modelClass
* @param \Carbon\Carbon $cutoffDate
* @param bool $isDryRun
* @return int Number of records anonymized
*/
protected function cleanupProfileTable(string $tableName, string $modelClass, $cutoffDate, bool $isDryRun): int
{
$this->line("\nProcessing {$tableName} table...");
// Find profiles with last_login_ip that should be anonymized
$query = $modelClass::whereNotNull('last_login_ip')
->where('last_login_ip', '!=', '')
->where(function ($q) use ($cutoffDate) {
// Anonymize if last_login_at is older than cutoff date
$q->where('last_login_at', '<', $cutoffDate)
// Or if last_login_at is null (should not happen, but handle it)
->orWhereNull('last_login_at');
});
$count = $query->count();
if ($count === 0) {
$this->line(" No IP addresses to anonymize in {$tableName}");
return 0;
}
if ($isDryRun) {
$this->warn(" Would anonymize {$count} IP addresses in {$tableName}");
// Show some examples in dry run
$examples = $query->take(3)->get(['id', 'name', 'last_login_ip', 'last_login_at']);
if ($examples->isNotEmpty()) {
$this->line(" Examples:");
foreach ($examples as $example) {
$loginDate = 'never';
if ($example->last_login_at) {
$loginDate = is_string($example->last_login_at)
? $example->last_login_at
: $example->last_login_at->toDateString();
}
$this->line(" - ID {$example->id} ({$example->name}): {$example->last_login_ip} (last login: {$loginDate})");
}
}
} else {
// Anonymize by setting to null
$updated = $query->update(['last_login_ip' => null]);
$this->info(" ✓ Anonymized {$updated} IP addresses in {$tableName}");
}
return $count;
}
/**
* Cleanup IP addresses from activity log
*
* @param \Carbon\Carbon $cutoffDate
* @param bool $isDryRun
* @return int Number of records anonymized
*/
protected function cleanupActivityLog($cutoffDate, bool $isDryRun): int
{
$this->line("\nProcessing activity_log table...");
// Find activity logs with IP addresses older than cutoff date
$query = Activity::whereNotNull('properties->ip')
->where('created_at', '<', $cutoffDate);
$count = $query->count();
if ($count === 0) {
$this->line(" No IP addresses to anonymize in activity_log");
return 0;
}
if ($isDryRun) {
$this->warn(" Would anonymize {$count} IP addresses in activity_log");
// Show some examples in dry run
$examples = $query->take(3)->get(['id', 'log_name', 'properties', 'created_at']);
if ($examples->isNotEmpty()) {
$this->line(" Examples:");
foreach ($examples as $example) {
$ip = $example->properties['ip'] ?? 'N/A';
$this->line(" - ID {$example->id} ({$example->log_name}): {$ip} (date: {$example->created_at->toDateString()})");
}
}
} else {
// Anonymize by removing IP from properties JSON
$activities = $query->get();
$updated = 0;
foreach ($activities as $activity) {
$properties = $activity->properties;
if (isset($properties['ip'])) {
unset($properties['ip']);
$activity->properties = $properties;
$activity->save();
$updated++;
}
}
$this->info(" ✓ Anonymized {$updated} IP addresses in activity_log");
}
return $count;
}
}
@@ -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;
}
}
+814
View File
@@ -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;
}
}
}
+985
View File
@@ -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;
}
}
+184
View File
@@ -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());
}
}
}
+780
View File
@@ -0,0 +1,780 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportExportTags extends Command
{
protected $signature = 'tags:import-export
{action : Action to perform: import, export-categories, export-tags, remove-group}
{file? : Specific JSON file path (optional for import - will process all files in imports/tags/ if not specified)}
{--category-id= : Export tags for specific category ID only}
{--locale= : Export tags for specific locale only}
{--output= : Output file name (optional, defaults to generated name)}
{--dry-run : Preview import without making changes}';
protected $description = 'Import/export tags and categories in JSON format for AI tag generation';
protected array $supportedLocales = ['en', 'nl', 'fr', 'es', 'de'];
public function handle()
{
$action = $this->argument('action');
switch ($action) {
case 'import':
return $this->importTags();
case 'export-categories':
return $this->exportCategories();
case 'export-tags':
return $this->exportTags();
case 'remove-group':
return $this->removeTagGroups();
default:
$this->error("Invalid action. Use: import, export-categories, export-tags, or remove-group");
return 1;
}
}
/**
* Import tags from JSON file(s)
*/
protected function importTags(): int
{
$filePath = $this->argument('file');
$dryRun = $this->option('dry-run');
// If no specific file provided, process all files in imports/tags/ folder
if (!$filePath) {
return $this->importFromFolder($dryRun);
}
// Process single file
return $this->importSingleFile($filePath, $dryRun);
}
/**
* Import all JSON files from imports/tags/ folder
*/
protected function importFromFolder(bool $dryRun): int
{
$importFolder = 'imports/tags';
// Create folder if it doesn't exist
if (!is_dir($importFolder)) {
mkdir($importFolder, 0755, true);
$this->info("Created imports folder: {$importFolder}");
$this->info("Please place your JSON files in this folder and run the command again.");
return 0;
}
// Get all JSON files from the folder
$jsonFiles = glob($importFolder . '/*.json');
if (empty($jsonFiles)) {
$this->warn("No JSON files found in {$importFolder}/");
$this->info("Please place your JSON files in this folder and run the command again.");
return 0;
}
$this->info("Found " . count($jsonFiles) . " JSON files to process:");
foreach ($jsonFiles as $file) {
$this->line(" - " . basename($file));
}
if ($dryRun) {
$this->warn("DRY RUN MODE - No changes will be made");
}
$this->newLine();
$totalImported = 0;
$totalSkipped = 0;
$totalErrors = 0;
$processedFiles = 0;
$failedFiles = 0;
foreach ($jsonFiles as $filePath) {
$fileName = basename($filePath);
$this->info("Processing file: {$fileName}");
$this->line(str_repeat('=', 50));
try {
$result = $this->importSingleFile($filePath, $dryRun);
if ($result === 0) {
$processedFiles++;
// Get the stats from the last import (we'll need to modify importSingleFile to return stats)
} else {
$failedFiles++;
$this->error("Failed to process {$fileName}");
}
} catch (\Exception $e) {
$failedFiles++;
$this->error("Error processing {$fileName}: " . $e->getMessage());
}
$this->newLine();
}
// Final summary
$this->info("Overall Import Summary:");
$this->line(" Files processed successfully: {$processedFiles}");
$this->line(" Files failed: {$failedFiles}");
$this->line(" Total files: " . count($jsonFiles));
return $failedFiles > 0 ? 1 : 0;
}
/**
* Import tags from a single JSON file
*/
protected function importSingleFile(string $filePath, bool $dryRun): int
{
if (!file_exists($filePath)) {
$this->error("File not found: {$filePath}");
return 1;
}
$jsonContent = file_get_contents($filePath);
$data = json_decode($jsonContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->error('Invalid JSON format: ' . json_last_error_msg());
return 1;
}
if (!isset($data['tags']) || !is_array($data['tags'])) {
$this->error('JSON must contain a "tags" array');
return 1;
}
$this->info("Found " . count($data['tags']) . " tags to import from " . basename($filePath));
$imported = 0;
$skipped = 0;
$errors = 0;
foreach ($data['tags'] as $index => $tagData) {
try {
$result = $this->importSingleTag($tagData, $index + 1, $dryRun);
if ($result === 'imported') {
$imported++;
} elseif ($result === 'skipped') {
$skipped++;
}
} catch (\Exception $e) {
$this->error("Error processing tag at index " . ($index + 1) . ": " . $e->getMessage());
$errors++;
}
}
$this->newLine();
$this->info("File Summary for " . basename($filePath) . ":");
$this->line(" Imported: {$imported}");
$this->line(" Skipped: {$skipped}");
$this->line(" Errors: {$errors}");
// Move processed file to processed folder (only if not dry run and no errors)
if (!$dryRun && $errors === 0) {
$this->moveProcessedFile($filePath);
}
return $errors > 0 ? 1 : 0;
}
/**
* Move processed file to processed folder
*/
protected function moveProcessedFile(string $filePath): void
{
$processedFolder = 'imports/tags/processed';
if (!is_dir($processedFolder)) {
mkdir($processedFolder, 0755, true);
}
$fileName = basename($filePath);
$timestamp = now()->format('Y-m-d-H-i-s');
$newFileName = pathinfo($fileName, PATHINFO_FILENAME) . "_{$timestamp}.json";
$newPath = $processedFolder . '/' . $newFileName;
if (rename($filePath, $newPath)) {
$this->line(" ✓ Moved processed file to: {$newPath}");
} else {
$this->warn(" Could not move processed file to processed folder");
}
}
/**
* Import a single tag from the JSON data
*/
protected function importSingleTag(array $tagData, int $index, bool $dryRun): string
{
// Validate required fields
if (!isset($tagData['translations']) || !is_array($tagData['translations'])) {
throw new \InvalidArgumentException("Tag {$index}: 'translations' field is required and must be an array");
}
if (!isset($tagData['category'])) {
throw new \InvalidArgumentException("Tag {$index}: 'category' field is required");
}
$category = $tagData['category'];
// Verify category exists
$categoryId = null;
if (isset($category['id'])) {
$categoryId = $category['id'];
} elseif (isset($category['name'])) {
// Find category by name
$categoryRecord = DB::table('category_translations')
->join('categories', 'category_translations.category_id', '=', 'categories.id')
->where('category_translations.name', $category['name'])
->where('category_translations.locale', 'en')
->where('categories.type', 'App\\Models\\Tag')
->first();
if (!$categoryRecord) {
throw new \InvalidArgumentException("Tag {$index}: Category '{$category['name']}' not found");
}
$categoryId = $categoryRecord->category_id;
} else {
throw new \InvalidArgumentException("Tag {$index}: Category must have either 'id' or 'name' field");
}
// Verify category exists
$categoryExists = DB::table('categories')
->where('id', $categoryId)
->where('type', 'App\\Models\\Tag')
->exists();
if (!$categoryExists) {
throw new \InvalidArgumentException("Tag {$index}: Category with ID {$categoryId} not found");
}
// Check if this exact tag group already exists
$existingTagGroup = $this->findExistingTagGroup($tagData['translations'], $categoryId);
if ($existingTagGroup) {
$this->line(" Skipping existing tag group with translations: " . implode(', ', array_values($tagData['translations'])));
return 'skipped';
}
if ($dryRun) {
$this->line(" Would create new tag group for category {$categoryId}:");
foreach ($tagData['translations'] as $locale => $tagName) {
if (in_array($locale, $this->supportedLocales)) {
$this->line(" {$locale}: '{$tagName}'");
}
}
return 'imported';
}
// Create new context for this tag group (each imported tag group gets its own context)
$contextId = DB::table('taggable_contexts')->insertGetId([
'category_id' => $categoryId,
'updated_by_user' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$this->line(" Created new context {$contextId} for category {$categoryId}");
$createdTags = [];
// Process each translation - each gets its own tag_id
foreach ($tagData['translations'] as $locale => $tagName) {
if (!in_array($locale, $this->supportedLocales)) {
$this->warn(" Skipping unsupported locale: {$locale}");
continue;
}
// Create the tag
$tagId = DB::table('taggable_tags')->insertGetId([
'name' => $tagName,
'normalized' => Str::lower($tagName),
'created_at' => now(),
'updated_at' => now(),
]);
// Create locale record
DB::table('taggable_locales')->insert([
'taggable_tag_id' => $tagId,
'locale' => $locale,
'comment' => '',
'updated_by_user' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
// Link to the same context (this creates the relationship between all translations)
DB::table('taggable_locale_context')->insert([
'tag_id' => $tagId,
'context_id' => $contextId,
]);
$createdTags[] = ['locale' => $locale, 'name' => $tagName, 'id' => $tagId];
$this->line(" ✓ Created tag: '{$tagName}' ({$locale}) with ID {$tagId}");
}
$this->line(" ✓ Created tag group with " . count($createdTags) . " translations in context {$contextId}");
return 'imported';
}
/**
* Check if this exact tag group already exists
*/
protected function findExistingTagGroup(array $translations, int $categoryId): ?int
{
// Look for any existing tag with any of these names in the same category
foreach ($translations as $locale => $tagName) {
if (!in_array($locale, $this->supportedLocales)) {
continue;
}
$existingTag = DB::table('taggable_tags as tt')
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
->join('taggable_contexts as tc', 'tlc.context_id', '=', 'tc.id')
->where('tt.name', $tagName)
->where('tl.locale', $locale)
->where('tc.category_id', $categoryId)
->first();
if ($existingTag) {
// Found an existing tag, now check if the context has all the same translations
$contextId = $existingTag->context_id;
$existingTranslations = DB::table('taggable_tags as tt')
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
->where('tlc.context_id', $contextId)
->pluck('tt.name', 'tl.locale')
->toArray();
// Check if existing translations match exactly
$inputTranslationsFiltered = array_intersect_key($translations, array_flip($this->supportedLocales));
if (
count(array_diff_assoc($inputTranslationsFiltered, $existingTranslations)) === 0 &&
count(array_diff_assoc($existingTranslations, $inputTranslationsFiltered)) === 0
) {
return $contextId;
}
}
}
return null;
}
/**
* Export categories to JSON format for AI tag generation
*/
protected function exportCategories(): int
{
$this->info('Exporting categories...');
// Create export folder if it doesn't exist
$exportFolder = 'exports/categories';
if (!is_dir($exportFolder)) {
mkdir($exportFolder, 0755, true);
$this->info("Created export folder: {$exportFolder}");
}
$categories = DB::table('categories')
->join('category_translations', 'categories.id', '=', 'category_translations.category_id')
->where('categories.type', 'App\\Models\\Tag')
->where('category_translations.locale', 'en')
->select(
'categories.id',
'category_translations.name',
'category_translations.slug',
'categories.color'
)
->orderBy('category_translations.name')
->get();
$exportData = [
'metadata' => [
'exported_at' => now()->toISOString(),
'total_categories' => $categories->count(),
'purpose' => 'AI tag generation input',
'supported_locales' => $this->supportedLocales,
'instructions' => [
'Please generate tags for the categories below',
'Follow the example_format exactly',
'Include translations for all supported locales',
'Use the category id and name provided',
'Generate 10-20 relevant tags per category',
],
],
'categories' => $categories->map(function ($category) {
return [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
'color' => $category->color,
];
})->toArray(),
'example_format' => [
'tags' => [
[
'translations' => [
'en' => 'Example English Tag',
'nl' => 'Voorbeeld Nederlandse Tag',
'fr' => 'Exemple de Tag Français',
'es' => 'Ejemplo de Etiqueta Española',
'de' => 'Beispiel Deutsche Tag',
],
'category' => [
'id' => 1,
'name' => 'Category Name'
]
]
]
]
];
$fileName = $this->option('output') ?: 'categories-for-ai-' . now()->format('Y-m-d-H-i-s') . '.json';
$fullPath = $exportFolder . '/' . $fileName;
file_put_contents($fullPath, json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->info("✓ Exported {$categories->count()} categories to: {$fullPath}");
$this->newLine();
$this->info("Next steps:");
$this->line("1. Send the exported file to an AI service");
$this->line("2. Ask AI to generate tags following the example_format");
$this->line("3. Save AI response as JSON file(s) in imports/tags/ folder");
$this->line("4. Run: php artisan tags:import-export import");
$this->newLine();
$this->info("Folder structure:");
$this->line(" exports/categories/ - Generated category files for AI");
$this->line(" imports/tags/ - Place AI-generated tag files here");
$this->line(" imports/tags/processed/ - Successfully processed files are moved here");
return 0;
}
/**
* Interactive removal of tag groups
*/
protected function removeTagGroups(): int
{
$this->info('Interactive Tag Group Removal');
$this->newLine();
$this->info('This tool allows you to remove entire tag groups (all translations of the same concept).');
$this->warn('⚠️ You can press Ctrl+C at any time to abort this script.');
$this->newLine();
$removedGroups = 0;
while (true) {
$tagId = $this->ask('Enter a taggable_tag_id to remove its entire group (or "exit" to quit)');
if (strtolower(trim($tagId)) === 'exit') {
break;
}
if (!is_numeric($tagId) || $tagId <= 0) {
$this->error('Please enter a valid numeric tag ID.');
continue;
}
$tagId = (int) $tagId;
try {
$result = $this->removeTagGroup($tagId);
if ($result) {
$removedGroups++;
}
} catch (\Exception $e) {
$this->error('Error: ' . $e->getMessage());
}
$this->newLine();
}
$this->newLine();
$this->info("Session summary: Removed {$removedGroups} tag groups.");
return 0;
}
/**
* Remove a single tag group by tag ID
*/
protected function removeTagGroup(int $tagId): bool
{
// Find the tag and its context
$tagInfo = DB::table('taggable_tags as tt')
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
->join('taggable_contexts as tc', 'tlc.context_id', '=', 'tc.id')
->join('categories as c', 'tc.category_id', '=', 'c.id')
->join('category_translations as ct', function ($join) {
$join->on('c.id', '=', 'ct.category_id')
->where('ct.locale', '=', 'en');
})
->where('tt.tag_id', $tagId)
->select(
'tt.tag_id',
'tt.name',
'tl.locale',
'tc.id as context_id',
'c.id as category_id',
'ct.name as category_name'
)
->first();
if (!$tagInfo) {
$this->error("Tag with ID {$tagId} not found.");
return false;
}
// Get all tags in the same context (the entire tag group)
$tagGroup = DB::table('taggable_tags as tt')
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
->where('tlc.context_id', $tagInfo->context_id)
->select(
'tt.tag_id',
'tt.name',
'tl.locale'
)
->orderBy('tl.locale')
->get();
if ($tagGroup->isEmpty()) {
$this->error("No tag group found for tag ID {$tagId}.");
return false;
}
// Display the tag group information
$this->info("Found tag group in category: {$tagInfo->category_name}");
$this->info("Context ID: {$tagInfo->context_id}");
$this->newLine();
$this->warn("The following tags will be PERMANENTLY removed:");
foreach ($tagGroup as $tag) {
$this->line(" • ID {$tag->tag_id}: '{$tag->name}' ({$tag->locale})");
}
$this->newLine();
$confirmMessage = "Are you sure you want to remove this entire tag group ({$tagGroup->count()} tags)?";
if (!$this->confirm($confirmMessage, false)) {
$this->info('Removal cancelled.');
return false;
}
// Perform the removal
$this->info('Removing tag group...');
try {
DB::transaction(function () use ($tagGroup, $tagInfo) {
$tagIds = $tagGroup->pluck('tag_id')->toArray();
// Remove context links
DB::table('taggable_locale_context')
->whereIn('tag_id', $tagIds)
->delete();
// Remove taggable relationships (if any exist)
DB::table('taggable_taggables')
->whereIn('tag_id', $tagIds)
->delete();
// Remove locale records
DB::table('taggable_locales')
->whereIn('taggable_tag_id', $tagIds)
->delete();
// Remove the tags themselves
DB::table('taggable_tags')
->whereIn('tag_id', $tagIds)
->delete();
// Remove the context if it's now empty
$remainingTags = DB::table('taggable_locale_context')
->where('context_id', $tagInfo->context_id)
->count();
if ($remainingTags === 0) {
DB::table('taggable_contexts')
->where('id', $tagInfo->context_id)
->delete();
$this->line(" ✓ Removed empty context {$tagInfo->context_id}");
}
});
$this->info("✓ Successfully removed tag group ({$tagGroup->count()} tags)");
// Show what was removed
foreach ($tagGroup as $tag) {
$this->line(" ✓ Removed: '{$tag->name}' ({$tag->locale}) - ID {$tag->tag_id}");
}
return true;
} catch (\Exception $e) {
$this->error('Failed to remove tag group: ' . $e->getMessage());
return false;
}
}
/**
* Find existing tag group helper method
/**
* Export existing tags to JSON format
*/
protected function exportTags(): int
{
$categoryId = $this->option('category-id');
$locale = $this->option('locale');
$this->info('Exporting tags...');
// Create export folder if it doesn't exist
$exportFolder = 'exports/tags';
if (!is_dir($exportFolder)) {
mkdir($exportFolder, 0755, true);
$this->info("Created export folder: {$exportFolder}");
}
$query = DB::table('taggable_tags as tt')
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
->join('taggable_contexts as tc', 'tlc.context_id', '=', 'tc.id')
->join('categories as c', 'tc.category_id', '=', 'c.id')
->join('category_translations as ct', function ($join) {
$join->on('c.id', '=', 'ct.category_id')
->where('ct.locale', '=', 'en');
})
->select(
'tt.tag_id',
'tt.name',
'tl.locale',
'tc.id as context_id',
'c.id as category_id',
'ct.name as category_name'
);
if ($categoryId) {
$query->where('c.id', $categoryId);
}
if ($locale) {
$query->where('tl.locale', $locale);
}
$tags = $query->orderBy('c.id')
->orderBy('tc.id')
->orderBy('tl.locale')
->get();
// Group tags by context (since each context now contains one tag group)
$contextGroups = [];
foreach ($tags as $tag) {
$contextId = $tag->context_id;
if (!isset($contextGroups[$contextId])) {
$contextGroups[$contextId] = [
'category_id' => $tag->category_id,
'category_name' => $tag->category_name,
'translations' => [],
];
}
// Add this translation to the context group
$contextGroups[$contextId]['translations'][$tag->locale] = $tag->name;
}
// Convert to export format - each context becomes one tag group
$exportTags = [];
foreach ($contextGroups as $contextId => $contextData) {
// Skip contexts that don't have any translations (shouldn't happen, but safety check)
if (empty($contextData['translations'])) {
continue;
}
// If filtering by locale, only include contexts that have that locale
if ($locale && !isset($contextData['translations'][$locale])) {
continue;
}
$exportTags[] = [
'translations' => $contextData['translations'],
'category' => [
'id' => $contextData['category_id'],
'name' => $contextData['category_name'],
],
'_metadata' => [
'context_id' => $contextId,
'translation_count' => count($contextData['translations']),
],
];
}
$exportData = [
'metadata' => [
'exported_at' => now()->toISOString(),
'total_tag_groups' => count($exportTags),
'total_contexts' => count($contextGroups),
'filters' => [
'category_id' => $categoryId,
'locale' => $locale,
],
'structure_info' => [
'each_tag_group' => 'represents one context with 1-5 translations',
'context_per_concept' => 'each context contains translations of the same concept',
'max_translations_per_group' => 5,
'supported_locales' => $this->supportedLocales,
],
],
'tags' => $exportTags,
];
$fileName = $this->option('output') ?: 'tags-backup-' . now()->format('Y-m-d-H-i-s') . '.json';
if ($categoryId) {
$fileName = str_replace('.json', "-category-{$categoryId}.json", $fileName);
}
if ($locale) {
$fileName = str_replace('.json', "-{$locale}.json", $fileName);
}
$fullPath = $exportFolder . '/' . $fileName;
file_put_contents($fullPath, json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->info("✓ Exported " . count($exportTags) . " tag groups from " . count($contextGroups) . " contexts to: {$fullPath}");
$this->newLine();
// Show some statistics
$translationCounts = array_count_values(array_map(fn($tag) => count($tag['translations']), $exportTags));
$this->info("Translation distribution:");
foreach ($translationCounts as $count => $groups) {
$this->line(" {$count} translation(s): {$groups} tag groups");
}
if ($locale) {
$this->newLine();
$this->info("Note: When filtering by locale '{$locale}', only contexts containing that locale are included.");
$this->info("Each exported tag group shows all translations for that context, not just the filtered locale.");
}
return 0;
}
}
@@ -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);
}
}
+35
View File
@@ -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;
}
}
+102
View File
@@ -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) . ']' : '[]';
}
}
+51
View File
@@ -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;
}
}
+55
View File
@@ -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;
}
}
+668
View File
@@ -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;
}
}
+30
View File
@@ -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;
}
}
+457
View File
@@ -0,0 +1,457 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use App\Models\User;
use App\Models\Organization;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Account;
use App\Mail\CallBlockedMail;
use App\Mail\CallExpiredMail;
use App\Mail\CallExpiringMail;
use App\Mail\InactiveProfileWarning1Mail;
use App\Models\Call;
use App\Mail\InactiveProfileWarning2Mail;
use App\Mail\InactiveProfileWarningFinalMail;
use App\Mail\UserDeletedMail;
use App\Mail\TransferReceived;
use App\Mail\ProfileLinkChangedMail;
use App\Mail\ProfileEditedByAdminMail;
use App\Mail\VerifyProfileEmailMailable;
use App\Mail\ReservationCreatedMail;
use App\Mail\ReservationCancelledMail;
use App\Mail\ReservationUpdateMail;
use App\Mail\ReactionCreatedMail;
use App\Mail\TagAddedMail;
class SendTestEmail extends Command
{
protected $signature = 'email:send-test
{--type= : Email type to send (use --list to see all types)}
{--receiver= : Receiver type (user, organization, admin, bank)}
{--id= : Receiver ID}
{--list : List all available email types}
{--queue : Send via queue instead of immediately}';
protected $description = 'Send test mailing transactional emails for testing and review';
protected $emailTypes = [
'inactive-warning-1' => [
'class' => InactiveProfileWarning1Mail::class,
'description' => 'Inactive profile warning 1 (first warning)',
'supports' => ['user', 'organization'],
],
'inactive-warning-2' => [
'class' => InactiveProfileWarning2Mail::class,
'description' => 'Inactive profile warning 2 (second warning)',
'supports' => ['user', 'organization'],
],
'inactive-warning-final' => [
'class' => InactiveProfileWarningFinalMail::class,
'description' => 'Inactive Profile Final Warning (last warning)',
'supports' => ['user', 'organization'],
],
'user-deleted' => [
'class' => UserDeletedMail::class,
'description' => 'User Deleted Notification',
'supports' => ['user'],
],
'transfer-received' => [
'class' => TransferReceived::class,
'description' => 'Transfer/Payment Received Notification',
'supports' => ['user', 'organization'],
],
'profile-link-changed' => [
'class' => ProfileLinkChangedMail::class,
'description' => 'Profile Link/Name Changed Notification',
'supports' => ['user', 'organization', 'admin', 'bank'],
],
'profile-edited-by-admin' => [
'class' => ProfileEditedByAdminMail::class,
'description' => 'Profile Edited by Admin Notification',
'supports' => ['user', 'organization'],
],
'verify-email' => [
'class' => VerifyProfileEmailMailable::class,
'description' => 'Email Verification Request',
'supports' => ['user', 'organization'],
],
'reservation-created' => [
'class' => ReservationCreatedMail::class,
'description' => 'Reservation Created Notification',
'supports' => ['user', 'organization'],
],
'reservation-cancelled' => [
'class' => ReservationCancelledMail::class,
'description' => 'Reservation Cancelled Notification',
'supports' => ['user', 'organization'],
],
'reservation-updated' => [
'class' => ReservationUpdateMail::class,
'description' => 'Reservation Updated Notification',
'supports' => ['user', 'organization'],
],
'reaction-created' => [
'class' => ReactionCreatedMail::class,
'description' => 'Reaction/Comment Created Notification',
'supports' => ['user', 'organization'],
],
'tag-added' => [
'class' => TagAddedMail::class,
'description' => 'Tag Added to Profile Notification',
'supports' => ['user', 'organization'],
],
'call-expired' => [
'class' => CallExpiredMail::class,
'description' => 'Call Expired Notification',
'supports' => ['user', 'organization', 'bank'],
],
'call-expiring' => [
'class' => CallExpiringMail::class,
'description' => 'Call Expiring Soon Warning',
'supports' => ['user', 'organization', 'bank'],
],
'call-blocked' => [
'class' => CallBlockedMail::class,
'description' => 'Call Blocked by Admin Notification',
'supports' => ['user', 'organization', 'bank'],
],
];
public function handle()
{
if ($this->option('list')) {
return $this->listEmailTypes();
}
$type = $this->option('type');
$receiverType = $this->option('receiver');
$receiverId = $this->option('id');
// Interactive mode if no options provided
if (!$type || !$receiverType || !$receiverId) {
return $this->interactiveMode();
}
return $this->sendEmail($type, $receiverType, $receiverId);
}
protected function listEmailTypes()
{
$this->info('Available Email Types:');
$this->newLine();
foreach ($this->emailTypes as $key => $config) {
$supports = implode(', ', $config['supports']);
$this->line(" <fg=cyan>{$key}</>");
$this->line(" Description: {$config['description']}");
$this->line(" Supports: {$supports}");
$this->newLine();
}
$this->info('Usage Example:');
$this->line(' php artisan email:send-test --type=inactive-warning-1 --receiver=user --id=102');
$this->newLine();
return 0;
}
protected function interactiveMode()
{
$this->info('📧 Test Email Sender - Interactive Mode');
$this->newLine();
// Select email type
$typeChoices = array_map(
fn ($key, $config) => "{$key} - {$config['description']}",
array_keys($this->emailTypes),
array_values($this->emailTypes)
);
$selectedIndex = array_search(
$this->choice('Select email type', $typeChoices),
$typeChoices
);
$type = array_keys($this->emailTypes)[$selectedIndex];
// Select receiver type
$supports = $this->emailTypes[$type]['supports'];
$receiverType = $this->choice('Select receiver type', $supports);
// Enter receiver ID
$receiverId = $this->ask('Enter receiver ID');
return $this->sendEmail($type, $receiverType, $receiverId);
}
protected function sendEmail($type, $receiverType, $receiverId)
{
if (!isset($this->emailTypes[$type])) {
$this->error("Invalid email type: {$type}");
$this->info('Use --list to see all available types');
return 1;
}
$config = $this->emailTypes[$type];
if (!in_array($receiverType, $config['supports'])) {
$this->error("Email type '{$type}' does not support receiver type '{$receiverType}'");
$this->info('Supported types: ' . implode(', ', $config['supports']));
return 1;
}
// Get receiver profile
$receiver = $this->getReceiver($receiverType, $receiverId);
if (!$receiver) {
$this->error("Receiver not found: {$receiverType} #{$receiverId}");
return 1;
}
$this->info("Sending '{$type}' email to {$receiver->name} ({$receiver->email})");
$this->newLine();
try {
$mailable = $this->buildMailable($type, $receiver, $receiverType);
if ($this->option('queue')) {
Mail::to($receiver->email)->queue($mailable);
$this->info('✅ Email queued successfully');
$this->line('Run queue worker: php artisan queue:work --stop-when-empty');
} else {
Mail::to($receiver->email)->send($mailable);
$this->info('✅ Email sent successfully');
}
$this->newLine();
$this->line("Recipient: {$receiver->email}");
$this->line("Profile: {$receiver->name}");
$this->line("Language: " . ($receiver->lang_preference ?? 'en'));
return 0;
} catch (\Exception $e) {
$this->error('Failed to send email: ' . $e->getMessage());
$this->line($e->getTraceAsString());
return 1;
}
}
protected function getReceiver($type, $id)
{
return match($type) {
'user' => User::find($id),
'organization' => Organization::find($id),
'admin' => Admin::find($id),
'bank' => Bank::find($id),
default => null,
};
}
protected function buildMailable($type, $receiver, $receiverType)
{
// Get test data
$accounts = $this->getAccountsData($receiver);
$totalBalance = $this->getTotalBalance($accounts);
return match($type) {
'inactive-warning-1' => new InactiveProfileWarning1Mail(
$receiver,
ucfirst($receiverType),
'2 weeks',
14,
$accounts,
$totalBalance,
351
),
'inactive-warning-2' => new InactiveProfileWarning2Mail(
$receiver,
ucfirst($receiverType),
'1 week',
7,
$accounts,
$totalBalance,
358
),
'inactive-warning-final' => new InactiveProfileWarningFinalMail(
$receiver,
ucfirst($receiverType),
'24 hours',
1,
$accounts,
$totalBalance,
365
),
'user-deleted' => new UserDeletedMail(
$receiver,
$accounts,
$totalBalance,
$this->getTransferTargetAccount()
),
'transfer-received' => $this->buildTransferReceivedMail($receiver),
'profile-link-changed' => new ProfileLinkChangedMail(
$receiver,
$this->getLinkedProfileForTest($receiver),
'attached'
),
'profile-edited-by-admin' => new ProfileEditedByAdminMail(
$receiver,
'Test Admin',
'Updated profile information for testing purposes'
),
'verify-email' => new VerifyProfileEmailMailable(
$receiver->email,
url('/verify-email/' . base64_encode($receiver->email))
),
'reservation-created' => $this->buildReservationMail($receiver, ReservationCreatedMail::class),
'reservation-cancelled' => $this->buildReservationMail($receiver, ReservationCancelledMail::class),
'reservation-updated' => $this->buildReservationMail($receiver, ReservationUpdateMail::class),
'reaction-created' => $this->buildReactionMail($receiver),
'tag-added' => $this->buildTagAddedMail($receiver),
'call-expired' => $this->buildCallExpiredMail($receiver, $receiverType),
'call-expiring' => $this->buildCallExpiringMail($receiver, $receiverType),
'call-blocked' => $this->buildCallBlockedMail($receiver, $receiverType),
default => throw new \Exception("Mailable builder not implemented for type: {$type}"),
};
}
protected function getAccountsData($profile)
{
$accounts = [];
$profileAccounts = $profile->accounts()->active()->notRemoved()->get();
foreach ($profileAccounts as $account) {
\Cache::forget("account_balance_{$account->id}");
$accounts[] = [
'id' => $account->id,
'name' => $account->name,
'balance' => $account->balance,
'balanceFormatted' => tbFormat($account->balance),
];
}
return $accounts;
}
protected function getTotalBalance($accounts)
{
return array_sum(array_column($accounts, 'balance'));
}
protected function getTransferTargetAccount()
{
// Get a random organization account or create test data
$account = Account::whereHasMorph('accountable', [Organization::class])
->active()
->notRemoved()
->first();
return $account ? [
'id' => $account->id,
'name' => $account->name,
] : [
'id' => 1,
'name' => 'Test Organization Account',
];
}
protected function buildTransferReceivedMail($receiver)
{
$senderAccount = $receiver->accounts()->active()->notRemoved()->first();
if (!$senderAccount) {
throw new \Exception('Receiver has no active accounts');
}
return new TransferReceived(
$receiver->name,
120, // 2 hours in minutes
tbFormat(120),
'Test Transfer',
$senderAccount->name,
$receiver->email,
url('/profile/' . $receiver->name)
);
}
protected function buildReservationMail($receiver, $mailClass)
{
$postTitle = 'Test Post - ' . $mailClass;
$postOwner = 'Test Post Owner';
$postUrl = url('/posts/test-post');
$reservationDate = now()->addDays(7)->format('Y-m-d H:i');
return new $mailClass(
$receiver->name,
$postTitle,
$postOwner,
$postUrl,
$reservationDate
);
}
protected function buildReactionMail($receiver)
{
return new ReactionCreatedMail(
$receiver->name,
'Test Commenter',
'Test Post Title',
'This is a test comment for email testing purposes.',
url('/posts/test-post')
);
}
protected function buildTagAddedMail($receiver)
{
return new TagAddedMail(
$receiver->name,
'Test Tag',
'Test Admin',
url('/profile/' . $receiver->name)
);
}
protected function getTestCall($receiver): Call
{
return Call::where('callable_type', get_class($receiver))
->where('callable_id', $receiver->id)
->with(['tag'])
->first()
?? Call::with(['tag'])->first()
?? throw new \Exception('No calls found for test');
}
protected function buildCallExpiredMail($receiver, $receiverType): CallExpiredMail
{
return new CallExpiredMail($this->getTestCall($receiver), $receiver, ucfirst($receiverType));
}
protected function buildCallExpiringMail($receiver, $receiverType): CallExpiringMail
{
return new CallExpiringMail($this->getTestCall($receiver), $receiver, ucfirst($receiverType), 7);
}
protected function buildCallBlockedMail($receiver, $receiverType): CallBlockedMail
{
return new CallBlockedMail($this->getTestCall($receiver), $receiver, ucfirst($receiverType));
}
protected function getLinkedProfileForTest($receiver)
{
// Find a different profile type to use as the linked profile
// If receiver is a User, find an Organization/Admin/Bank to link
// If receiver is Organization/Admin/Bank, find a User to link
if ($receiver instanceof User) {
// Try to find an organization first, then admin, then bank
$linked = Organization::first() ?? Admin::first() ?? Bank::first();
} else {
// For Organization/Admin/Bank, find a user
$linked = User::first();
}
// If we can't find any other profile, just return the same receiver
return $linked ?? $receiver;
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Console\Commands;
use App\Models\Locations\Location;
use Illuminate\Console\Command;
class SyncLocationDataCommand extends Command
{
protected $signature = 'locations:sync-hierarchy {--force : Force sync even if data exists}';
protected $description = 'Sync missing location hierarchy data (i.e. divisions, countries, from cities).';
public function handle()
{
$query = Location::query();
if (!$this->option('force')) {
// Only sync locations that are missing division data
$query->whereNotNull('city_id')->whereNull('division_id');
}
$locations = $query->get();
$syncedCount = 0;
$totalSynced = [];
$this->info("Processing {$locations->count()} locations...");
foreach ($locations as $location) {
try {
$synced = $location->syncAllLocationData();
if (!empty($synced)) {
$syncedCount++;
$totalSynced = array_merge($totalSynced, $synced);
$this->line("Location ID {$location->id}: " . implode(', ', $synced));
}
} catch (\Exception $e) {
$this->error("Failed to sync location ID {$location->id}: " . $e->getMessage());
}
}
$syncStats = array_count_values($totalSynced);
$this->info("\nCompleted syncing {$syncedCount} locations:");
foreach ($syncStats as $type => $count) {
$this->info(" - {$count} locations synced {$type}");
}
}
}
+227
View File
@@ -0,0 +1,227 @@
<?php
namespace App\Console\Commands;
use App\Models\MailingBounce;
use App\Models\User;
use App\Models\Organization;
use Illuminate\Console\Command;
class TestBounceSystem extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'test:bounce-system
{--email= : Email to test (default: creates test emails)}
{--scenario= : Test scenario: single, threshold-verification, threshold-suppression, multiple}';
/**
* The console command description.
*/
protected $description = 'Test the bounce handling system with simulated bounces';
/**
* Execute the console command.
*/
public function handle()
{
$scenario = $this->option('scenario') ?: 'single';
$email = $this->option('email');
switch ($scenario) {
case 'single':
$this->testSingleBounce($email);
break;
case 'threshold-verification':
$this->testVerificationThreshold($email);
break;
case 'threshold-suppression':
$this->testSuppressionThreshold($email);
break;
case 'multiple':
$this->testMultipleEmails();
break;
default:
$this->error("Unknown scenario: {$scenario}");
$this->info("Available scenarios: single, threshold-verification, threshold-suppression, multiple");
return 1;
}
return 0;
}
/**
* Test a single bounce (should not trigger any thresholds)
*/
protected function testSingleBounce(?string $email): void
{
$testEmail = $email ?: 'test-single@example.com';
$this->info("🧪 Testing Single Bounce for: {$testEmail}");
// Create a test user with verified email
$this->createTestUser($testEmail);
// Record a single hard bounce with definitive pattern
$bounce = MailingBounce::recordBounce(
$testEmail,
'hard',
'user unknown - mailbox does not exist'
);
$this->line("✅ Created bounce record ID: {$bounce->id}");
// Check the results
$stats = MailingBounce::getBounceStats($testEmail);
$this->displayResults($testEmail, $stats);
}
/**
* Test verification threshold (2 bounces)
*/
protected function testVerificationThreshold(?string $email): void
{
$testEmail = $email ?: 'test-verification@example.com';
$this->info("🧪 Testing Verification Reset Threshold for: {$testEmail}");
// Create test user with verified email
$user = $this->createTestUser($testEmail);
$this->line("📧 Created test user with verified email: {$user->email_verified_at}");
// Record first bounce with exact pattern from config
$this->line("1️⃣ Recording first hard bounce...");
MailingBounce::recordBounce($testEmail, 'hard', 'user unknown - definitive bounce');
$user->refresh();
$this->line(" User email_verified_at: " . ($user->email_verified_at ?: 'NULL'));
// Record second bounce (should trigger verification reset)
$this->line("2️⃣ Recording second hard bounce (should reset verification)...");
MailingBounce::recordBounce($testEmail, 'hard', 'mailbox unavailable - permanent failure');
$user->refresh();
$this->line(" User email_verified_at: " . ($user->email_verified_at ?: 'NULL'));
// Check results
$stats = MailingBounce::getBounceStats($testEmail);
$this->displayResults($testEmail, $stats);
if (!$user->email_verified_at) {
$this->info("✅ SUCCESS: Email verification was reset!");
} else {
$this->error("❌ FAILED: Email verification was NOT reset!");
}
}
/**
* Test suppression threshold (3 bounces)
*/
protected function testSuppressionThreshold(?string $email): void
{
$testEmail = $email ?: 'test-suppression@example.com';
$this->info("🧪 Testing Suppression Threshold for: {$testEmail}");
// Create test user
$user = $this->createTestUser($testEmail);
// Record three bounces
for ($i = 1; $i <= 3; $i++) {
$this->line("{$i}️⃣ Recording hard bounce #{$i}...");
MailingBounce::recordBounce($testEmail, 'hard', "user unknown - attempt {$i}");
$user->refresh();
$isSuppressed = MailingBounce::isSuppressed($testEmail);
$this->line(" Suppressed: " . ($isSuppressed ? 'YES' : 'NO'));
$this->line(" Email verified: " . ($user->email_verified_at ? 'YES' : 'NO'));
}
// Check final results
$stats = MailingBounce::getBounceStats($testEmail);
$this->displayResults($testEmail, $stats);
if ($stats['is_suppressed']) {
$this->info("✅ SUCCESS: Email was suppressed after 3 bounces!");
} else {
$this->error("❌ FAILED: Email was NOT suppressed!");
}
}
/**
* Test multiple emails with different scenarios
*/
protected function testMultipleEmails(): void
{
$this->info("🧪 Testing Multiple Email Scenarios");
$scenarios = [
'no-bounce@example.com' => 0,
'one-bounce@example.com' => 1,
'verification-reset@example.com' => 2,
'suppressed@example.com' => 3,
'over-threshold@example.com' => 5
];
foreach ($scenarios as $email => $bounceCount) {
$this->line("Setting up {$email} with {$bounceCount} bounces...");
$this->createTestUser($email);
for ($i = 1; $i <= $bounceCount; $i++) {
MailingBounce::recordBounce($email, 'hard', "user unknown - bounce {$i}");
}
}
$this->info("\n📊 Results Summary:");
foreach ($scenarios as $email => $expectedBounces) {
$stats = MailingBounce::getBounceStats($email);
$user = User::where('email', $email)->first();
$this->line("📧 {$email}:");
$this->line(" Hard bounces: {$stats['recent_hard_bounces']}");
$this->line(" Suppressed: " . ($stats['is_suppressed'] ? 'YES' : 'NO'));
$this->line(" Verified: " . ($user && $user->email_verified_at ? 'YES' : 'NO'));
}
}
/**
* Create a test user with verified email
*/
protected function createTestUser(string $email): User
{
// Remove existing test user if any
User::where('email', $email)->delete();
$user = User::create([
'name' => 'Test User ' . substr($email, 0, strpos($email, '@')),
'email' => $email,
'password' => bcrypt('password'),
]);
// Use forceFill since email_verified_at isn't in fillable
$user->forceFill(['email_verified_at' => now()])->save();
return $user;
}
/**
* Display test results
*/
protected function displayResults(string $email, array $stats): void
{
$this->info("\n📊 Test Results for {$email}:");
$this->line("Total bounces: {$stats['total_bounces']}");
$this->line("Recent hard bounces: {$stats['recent_hard_bounces']}");
$this->line("Is suppressed: " . ($stats['is_suppressed'] ? 'YES' : 'NO'));
$user = User::where('email', $email)->first();
if ($user) {
$this->line("Email verified: " . ($user->email_verified_at ? 'YES' : 'NO'));
}
$this->line("");
}
}
@@ -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;
}
}
+331
View File
@@ -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;
}
}