Initial commit

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

View File

@@ -0,0 +1,648 @@
<?php
namespace App\Console\Commands;
use App\Models\Category;
use App\Models\Locations\CityLocale;
use App\Models\Locations\CountryLocale;
use App\Models\Locations\DistrictLocale;
use App\Models\Locations\DivisionLocale;
use App\Models\Locations\Location;
use App\Models\Meeting;
use App\Models\Post;
use App\Models\PostTranslation;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use ZipArchive;
class RestorePosts extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'posts:restore
{file : Path to the backup file (ZIP archive or JSON file)}
{--profile-id= : Profile ID to assign as post owner (overrides active session)}
{--profile-type= : Profile type (User, Organization, Bank, Admin) to assign as post owner}
{--dry-run : Show what would be imported without making changes}
{--skip-existing : Skip posts with duplicate slugs instead of failing}
{--skip-media : Skip media restoration even if backup contains media files}
{--select : Interactively select which posts to restore}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Restore posts, post_translations, meetings, and media from a backup file (ZIP or JSON)';
/**
* Execute the console command.
*/
public function handle(): int
{
$filePath = $this->argument('file');
$dryRun = $this->option('dry-run');
$skipExisting = $this->option('skip-existing');
$skipMedia = $this->option('skip-media');
// Validate file exists
if (!File::exists($filePath)) {
$this->error("Backup file not found: {$filePath}");
return Command::FAILURE;
}
// Determine file type and extract if necessary
$isZip = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)) === 'zip';
$extractDir = null;
$backupData = null;
if ($isZip) {
// Extract ZIP archive
if (!class_exists('ZipArchive')) {
$this->error('ZipArchive extension is not available. Install php-zip extension.');
return Command::FAILURE;
}
$extractDir = storage_path('app/temp/restore_' . uniqid());
File::makeDirectory($extractDir, 0755, true);
$zip = new ZipArchive();
if ($zip->open($filePath) !== true) {
$this->error("Failed to open ZIP archive: {$filePath}");
File::deleteDirectory($extractDir);
return Command::FAILURE;
}
$zip->extractTo($extractDir);
$zip->close();
$this->info("Extracted ZIP archive to temporary directory");
// Read backup.json from extracted directory
$jsonPath = "{$extractDir}/backup.json";
if (!File::exists($jsonPath)) {
$this->error("Invalid ZIP archive: missing backup.json");
File::deleteDirectory($extractDir);
return Command::FAILURE;
}
$json = File::get($jsonPath);
} else {
// Read JSON file directly
$json = File::get($filePath);
}
$backupData = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->error("Invalid JSON file: " . json_last_error_msg());
if ($extractDir) {
File::deleteDirectory($extractDir);
}
return Command::FAILURE;
}
// Validate backup format
if (!isset($backupData['meta']) || !isset($backupData['posts'])) {
$this->error("Invalid backup file format. Missing 'meta' or 'posts' keys.");
if ($extractDir) {
File::deleteDirectory($extractDir);
}
return Command::FAILURE;
}
$includesMedia = $backupData['meta']['includes_media'] ?? false;
$mediaCount = $backupData['meta']['counts']['media_files'] ?? 0;
$this->info("Backup file info:");
$this->table(
['Property', 'Value'],
[
['Version', $backupData['meta']['version'] ?? 'unknown'],
['Created', $backupData['meta']['created_at'] ?? 'unknown'],
['Source Database', $backupData['meta']['source_database'] ?? 'unknown'],
['Posts', $backupData['meta']['counts']['posts'] ?? count($backupData['posts'])],
['Translations', $backupData['meta']['counts']['post_translations'] ?? 'unknown'],
['Meetings', $backupData['meta']['counts']['meetings'] ?? 'unknown'],
['Media Files', $mediaCount],
['Includes Media', $includesMedia ? 'Yes' : 'No'],
]
);
if ($includesMedia && $skipMedia) {
$this->warn("Media restoration will be skipped (--skip-media flag)");
}
// Handle post selection with --select flag
$postsToRestore = $backupData['posts'];
if ($this->option('select')) {
$baseLocale = config('app.locale');
// Build numbered list for display
$tableRows = [];
foreach ($backupData['posts'] as $index => $post) {
$title = null;
$locales = [];
foreach ($post['translations'] ?? [] as $translation) {
$locales[] = $translation['locale'];
if ($translation['locale'] === $baseLocale) {
$title = $translation['title'];
}
}
if ($title === null && !empty($post['translations'])) {
$title = $post['translations'][0]['title'];
}
$indicators = [];
if (!empty($post['meeting'])) $indicators[] = 'meeting';
if (!empty($post['media'])) $indicators[] = 'media';
$indicatorStr = $indicators ? ' [' . implode(', ', $indicators) . ']' : '';
$tableRows[] = [
$index + 1,
($title ?? 'Untitled') . $indicatorStr,
implode(', ', $locales),
];
}
$this->newLine();
$this->info('Available posts:');
$this->table(['#', 'Title', 'Locales'], $tableRows);
$this->info("Enter post numbers to restore (comma-separated, ranges with dash, or 'all').");
$this->info("Examples: 1,3,5 or 1-10 or 1-5,8,12-15 or all");
$input = $this->ask('Selection');
if (strtolower(trim($input)) !== 'all') {
$selectedIndices = $this->parseSelection($input, count($backupData['posts']));
if (empty($selectedIndices)) {
$this->error('No valid posts selected.');
if ($extractDir) {
File::deleteDirectory($extractDir);
}
return Command::FAILURE;
}
$postsToRestore = [];
foreach ($selectedIndices as $idx) {
$postsToRestore[] = $backupData['posts'][$idx];
}
$this->info("Selected " . count($postsToRestore) . " of " . count($backupData['posts']) . " posts.");
}
}
// Determine profile for post ownership
$profileId = $this->option('profile-id');
$profileType = $this->option('profile-type');
if ($profileId && $profileType) {
// Use provided profile
$profileType = $this->resolveProfileType($profileType);
if (!$profileType) {
$this->error("Invalid profile type. Use: User, Organization, Bank, or Admin");
return Command::FAILURE;
}
} else {
// Try to get from session (won't work in CLI, but check anyway)
$profileId = session('activeProfileId');
$profileType = session('activeProfileType');
if (!$profileId || !$profileType) {
$this->error("No active profile in session. Please provide --profile-id and --profile-type options.");
$this->info("Example: php artisan posts:restore backup.json --profile-id=1 --profile-type=User");
return Command::FAILURE;
}
}
// Validate profile exists
if (!class_exists($profileType)) {
$this->error("Profile type class not found: {$profileType}");
if ($extractDir) {
File::deleteDirectory($extractDir);
}
return Command::FAILURE;
}
$profile = $profileType::find($profileId);
if (!$profile) {
$this->error("Profile not found: {$profileType} with ID {$profileId}");
if ($extractDir) {
File::deleteDirectory($extractDir);
}
return Command::FAILURE;
}
$this->info("Posts will be assigned to: {$profile->name} ({$profileType} #{$profileId})");
// Build category type => id lookup for the target database
$categoryLookup = Category::pluck('id', 'type')->toArray();
if ($dryRun) {
$this->warn("DRY RUN MODE - No changes will be made");
}
if (!$dryRun && !$this->confirm("Do you want to proceed with the restore?")) {
$this->info("Restore cancelled.");
if ($extractDir) {
File::deleteDirectory($extractDir);
}
return Command::SUCCESS;
}
$stats = [
'posts_created' => 0,
'posts_skipped' => 0,
'posts_overwritten' => 0,
'translations_created' => 0,
'meetings_created' => 0,
'media_restored' => 0,
'media_skipped' => 0,
'category_not_found' => 0,
];
// Track "all" choices for duplicate handling
$skipAll = null;
$bar = $this->output->createProgressBar(count($postsToRestore));
$bar->start();
DB::beginTransaction();
try {
foreach ($postsToRestore as $postData) {
// Look up category_id by category_type
$categoryId = null;
if (!empty($postData['category_type'])) {
$categoryId = $categoryLookup[$postData['category_type']] ?? null;
if ($categoryId === null) {
$this->newLine();
$this->warn("Category type not found: {$postData['category_type']}");
$stats['category_not_found']++;
}
}
// Check for existing slugs
if (!empty($postData['translations'])) {
$existingSlugs = PostTranslation::withTrashed()
->whereIn('slug', array_column($postData['translations'], 'slug'))
->pluck('slug')
->toArray();
if (!empty($existingSlugs)) {
$this->newLine();
$this->warn("Duplicate slug(s) found: " . implode(', ', $existingSlugs));
// Determine action based on flags or prompt
$action = $skipAll ?? null;
if ($action === null && !$skipExisting) {
$action = $this->choice(
'What would you like to do?',
[
'skip' => 'Skip this post',
'overwrite' => 'Overwrite existing post(s)',
'skip_all' => 'Skip all duplicates',
'overwrite_all' => 'Overwrite all duplicates',
],
'skip'
);
if ($action === 'skip_all') {
$skipAll = 'skip';
$action = 'skip';
} elseif ($action === 'overwrite_all') {
$skipAll = 'overwrite';
$action = 'overwrite';
}
} elseif ($skipExisting) {
$action = 'skip';
}
if ($action === 'skip') {
$stats['posts_skipped']++;
$bar->advance();
continue;
} elseif ($action === 'overwrite') {
// Delete existing translations and their posts if they become empty
$existingTranslations = PostTranslation::withTrashed()
->whereIn('slug', $existingSlugs)
->get();
foreach ($existingTranslations as $existingTranslation) {
$postId = $existingTranslation->post_id;
$existingTranslation->forceDelete();
// Check if post has no more translations and delete it too
$remainingTranslations = PostTranslation::withTrashed()
->where('post_id', $postId)
->count();
if ($remainingTranslations === 0) {
$existingPost = Post::withTrashed()->find($postId);
if ($existingPost) {
// Delete related meetings first
Meeting::withTrashed()->where('post_id', $postId)->forceDelete();
$existingPost->forceDelete();
}
}
}
$stats['posts_overwritten']++;
}
}
}
if (!$dryRun) {
// Create post with profile ownership
$post = new Post();
$post->postable_id = $profileId;
$post->postable_type = $profileType;
$post->category_id = $categoryId;
// Don't set love_reactant_id - let PostObserver register it as reactant
$post->author_id = $postData['author_id'];
$post->author_model = $postData['author_model'];
$post->created_at = $postData['created_at'] ? new \DateTime($postData['created_at']) : now();
$post->updated_at = $postData['updated_at'] ? new \DateTime($postData['updated_at']) : now();
$post->save();
// Ensure post is registered as reactant (in case observer didn't fire)
if (!$post->isRegisteredAsLoveReactant()) {
$post->registerAsLoveReactant();
}
// Create translations
foreach ($postData['translations'] as $translationData) {
$translation = new PostTranslation();
$translation->post_id = $post->id;
$translation->locale = $translationData['locale'];
$translation->slug = $translationData['slug'];
$translation->title = $translationData['title'];
$translation->excerpt = $translationData['excerpt'];
$translation->content = $translationData['content'];
$translation->status = $translationData['status'];
$translation->updated_by_user_id = $translationData['updated_by_user_id'];
$translation->from = $translationData['from'] ? new \DateTime($translationData['from']) : null;
$translation->till = $translationData['till'] ? new \DateTime($translationData['till']) : null;
$translation->created_at = $translationData['created_at'] ? new \DateTime($translationData['created_at']) : now();
$translation->updated_at = $translationData['updated_at'] ? new \DateTime($translationData['updated_at']) : now();
$translation->save();
$stats['translations_created']++;
}
// Create meeting (hasOne relationship)
if (!empty($postData['meeting'])) {
$meetingData = $postData['meeting'];
// Look up meetingable by name and type
$meetingableId = null;
$meetingableType = null;
// Whitelist of allowed meetingable types to prevent arbitrary class instantiation
$allowedMeetingableTypes = [
\App\Models\User::class,
\App\Models\Organization::class,
\App\Models\Bank::class,
];
if (!empty($meetingData['meetingable_type']) && !empty($meetingData['meetingable_name'])) {
$meetingableType = $meetingData['meetingable_type'];
if (in_array($meetingableType, $allowedMeetingableTypes, true)) {
$meetingable = $meetingableType::where('name', $meetingData['meetingable_name'])->first();
if ($meetingable) {
$meetingableId = $meetingable->id;
}
}
}
$meeting = new Meeting();
$meeting->post_id = $post->id;
$meeting->meetingable_id = $meetingableId;
$meeting->meetingable_type = $meetingableId ? $meetingableType : null;
$meeting->venue = $meetingData['venue'];
$meeting->address = $meetingData['address'];
$meeting->price = $meetingData['price'];
$meeting->based_on_quantity = $meetingData['based_on_quantity'];
$meeting->transaction_type_id = $meetingData['transaction_type_id'];
$meeting->status = $meetingData['status'];
$meeting->from = $meetingData['from'] ? new \DateTime($meetingData['from']) : null;
$meeting->till = $meetingData['till'] ? new \DateTime($meetingData['till']) : null;
$meeting->created_at = $meetingData['created_at'] ? new \DateTime($meetingData['created_at']) : now();
$meeting->updated_at = $meetingData['updated_at'] ? new \DateTime($meetingData['updated_at']) : now();
$meeting->save();
// Create location if location data exists
if (!empty($meetingData['location'])) {
$locationIds = $this->lookupLocationIds($meetingData['location']);
if ($locationIds['country_id'] || $locationIds['division_id'] || $locationIds['city_id'] || $locationIds['district_id']) {
$location = new Location();
$location->locatable_id = $meeting->id;
$location->locatable_type = Meeting::class;
$location->country_id = $locationIds['country_id'];
$location->division_id = $locationIds['division_id'];
$location->city_id = $locationIds['city_id'];
$location->district_id = $locationIds['district_id'];
$location->save();
}
}
$stats['meetings_created']++;
}
// Restore media if available and not skipped
if (!$skipMedia && $extractDir && !empty($postData['media'])) {
$mediaData = $postData['media'];
$mediaPath = "{$extractDir}/{$mediaData['archive_path']}";
if (File::exists($mediaPath)) {
try {
$media = $post->addMedia($mediaPath)
->usingName($mediaData['name'])
->usingFileName($mediaData['file_name'])
->withCustomProperties($mediaData['custom_properties'] ?? [])
->toMediaCollection('posts');
// Dispatch conversion job to queue
$conversionCollection = \Spatie\MediaLibrary\Conversions\ConversionCollection::createForMedia($media);
if ($conversionCollection->isNotEmpty()) {
dispatch(new \Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob($conversionCollection, $media, false))
->onQueue('low');
}
$stats['media_restored']++;
} catch (\Exception $e) {
$this->newLine();
$this->warn("Failed to restore media for post {$post->id}: " . $e->getMessage());
$stats['media_skipped']++;
}
} else {
$this->newLine();
$this->warn("Media file not found in archive: {$mediaData['archive_path']}");
$stats['media_skipped']++;
}
}
} else {
// Dry run - just count
$stats['translations_created'] += count($postData['translations']);
$stats['meetings_created'] += !empty($postData['meeting']) ? 1 : 0;
if (!empty($postData['media'])) {
$stats['media_restored']++;
}
}
$stats['posts_created']++;
$bar->advance();
}
if (!$dryRun) {
DB::commit();
}
} catch (\Exception $e) {
DB::rollBack();
$this->newLine();
$this->error("Restore failed: " . $e->getMessage());
if ($extractDir) {
File::deleteDirectory($extractDir);
}
return Command::FAILURE;
}
// Clean up extracted files
if ($extractDir) {
File::deleteDirectory($extractDir);
$this->info("Cleaned up temporary files");
}
$bar->finish();
$this->newLine(2);
$this->info($dryRun ? "Dry run completed!" : "Restore completed successfully!");
$this->table(
['Metric', 'Value'],
[
['Posts Created', $stats['posts_created']],
['Posts Skipped', $stats['posts_skipped']],
['Posts Overwritten', $stats['posts_overwritten']],
['Translations Created', $stats['translations_created']],
['Meetings Created', $stats['meetings_created']],
['Media Restored', $stats['media_restored']],
['Media Skipped', $stats['media_skipped']],
['Categories Not Found', $stats['category_not_found']],
]
);
if ($stats['category_not_found'] > 0) {
$this->warn("Some posts were created without a category. You may need to assign categories manually.");
}
return Command::SUCCESS;
}
/**
* Resolve profile type string to full class name.
*/
private function resolveProfileType(string $type): ?string
{
$typeMap = [
'user' => \App\Models\User::class,
'organization' => \App\Models\Organization::class,
'bank' => \App\Models\Bank::class,
'admin' => \App\Models\Admin::class,
];
$normalized = strtolower(trim($type));
// Handle full class names - only allow known model classes
if (str_contains($type, '\\')) {
return in_array($type, $typeMap, true) ? $type : null;
}
return $typeMap[$normalized] ?? null;
}
/**
* Look up location IDs by names in the app's base locale.
* Returns null for any location component that cannot be found.
*/
private function lookupLocationIds(array $locationData): array
{
$baseLocale = config('app.locale');
$result = [
'country_id' => null,
'division_id' => null,
'city_id' => null,
'district_id' => null,
];
// Look up country by name
if (!empty($locationData['country_name'])) {
$countryLocale = CountryLocale::withoutGlobalScopes()
->where('name', $locationData['country_name'])
->where('locale', $baseLocale)
->first();
$result['country_id'] = $countryLocale?->country_id;
}
// Look up division by name
if (!empty($locationData['division_name'])) {
$divisionLocale = DivisionLocale::withoutGlobalScopes()
->where('name', $locationData['division_name'])
->where('locale', $baseLocale)
->first();
$result['division_id'] = $divisionLocale?->division_id;
}
// Look up city by name
if (!empty($locationData['city_name'])) {
$cityLocale = CityLocale::withoutGlobalScopes()
->where('name', $locationData['city_name'])
->where('locale', $baseLocale)
->first();
$result['city_id'] = $cityLocale?->city_id;
}
// Look up district by name
if (!empty($locationData['district_name'])) {
$districtLocale = DistrictLocale::withoutGlobalScopes()
->where('name', $locationData['district_name'])
->where('locale', $baseLocale)
->first();
$result['district_id'] = $districtLocale?->district_id;
}
return $result;
}
/**
* Parse a user selection string like "1,3,5-10" into an array of 0-based indices.
*/
private function parseSelection(string $input, int $total): array
{
$indices = [];
$parts = preg_split('/\s*,\s*/', trim($input));
foreach ($parts as $part) {
$part = trim($part);
if (preg_match('/^(\d+)-(\d+)$/', $part, $matches)) {
$start = max(1, (int) $matches[1]);
$end = min($total, (int) $matches[2]);
for ($i = $start; $i <= $end; $i++) {
$indices[] = $i - 1;
}
} elseif (preg_match('/^\d+$/', $part)) {
$num = (int) $part;
if ($num >= 1 && $num <= $total) {
$indices[] = $num - 1;
}
}
}
return array_values(array_unique($indices));
}
}