Files

990 lines
41 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Events\CheckoutableCheckedIn;
use App\Mail\BulkDeleteReportMail;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Console\Helper\ProgressBar;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\error;
use function Laravel\Prompts\info;
use function Laravel\Prompts\multisearch;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\search;
use function Laravel\Prompts\select;
use function Laravel\Prompts\warning;
class BulkDelete extends Command
{
protected $signature = 'snipeit:checkin-delete-items';
protected $description = 'Interactively check in and/or delete items by company and type';
private const CHECKIN_NOTE = 'Checked in via bulk CLI operation';
private array $reportLines = [];
public function handle(): int
{
// Step 1: Dry run?
$dryRun = confirm(
label: 'Is this a dry run?',
default: true,
yes: 'Yes — preview only, no changes will be made',
no: 'No — LIVE RUN, changes WILL be made',
);
// Step 2: Who are you?
$adminId = search(
label: 'Who are you? Search by username, first or last name.',
placeholder: 'Type to search users...',
options: function (string $value): array {
if (strlen($value) < 1) {
return [];
}
return User::where('activated', 1)
->whereNull('deleted_at')
->onlySuperAdmins()
->where(function ($query) use ($value) {
$query->where('username', 'like', "%{$value}%")
->orWhere('first_name', 'like', "%{$value}%")
->orWhere('last_name', 'like', "%{$value}%")
->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", ["%{$value}%"]);
})
->get()
->mapWithKeys(fn (User $u) => [$u->id => "{$u->first_name} {$u->last_name} ({$u->username})"])
->toArray();
},
validate: fn (mixed $value) => ! $value ? 'A valid active user is required.' : null,
);
/** @var User $admin */
$admin = User::findOrFail((int) $adminId);
// Step 3: Which companies?
if (! Company::exists()) {
error('No companies found. Please create at least one company before using this command.');
return 1;
}
$selectedCompanyKeys = multisearch(
label: 'Which companies would you like to check in and delete items for?',
placeholder: 'Type to search companies...',
options: function (string $value): array {
$results = [];
if ($value === '' || str_contains('(no company / unassigned)', strtolower($value))) {
$results['__null__'] = '(No Company / Unassigned)';
}
$query = Company::orderBy('name');
if ($value !== '') {
$query->where('name', 'like', "%{$value}%");
}
$query->get()->each(function (Company $c) use (&$results) {
$results[$c->id] = "{$c->name} (ID: {$c->id})";
});
return $results;
},
scroll: 10,
required: 'Please select at least one company.',
hint: 'If you\'re searching on several differently named companies, use the up-arrow to go back to the search box to search again. ',
);
$includeNullCompany = in_array('__null__', $selectedCompanyKeys);
$selectedCompanyIds = array_values(array_filter(
$selectedCompanyKeys,
fn ($k) => $k !== '__null__'
));
$companyNamesById = Company::whereIn('id', $selectedCompanyIds)->pluck('name', 'id')->toArray();
$selectedCompanyNames = array_map(
fn ($id) => $id === '__null__' ? '(No Company)' : ($companyNamesById[$id] ?? "(ID: {$id})"),
$selectedCompanyKeys
);
// Step 4: Which item types?
$rawTypeSelection = multiselect(
label: 'What item types would you like to check in and delete?',
options: [
'all' => 'All Items (assets, licenses, accessories, components, consumables, users)',
'assets' => 'Assets',
'licenses' => 'Licenses',
'accessories' => 'Accessories',
'components' => 'Components',
'consumables' => 'Consumables',
'users' => 'Users',
],
required: 'Please select at least one item type.',
hint: 'Select "All Items" to process every supported type.',
);
$allSubTypes = ['assets', 'licenses', 'accessories', 'components', 'consumables', 'users'];
$selectedTypes = in_array('all', $rawTypeSelection)
? $allSubTypes
: array_values(array_intersect($allSubTypes, $rawTypeSelection));
// Compute and display counts now so the user can see what will be affected
$counts = $this->getCounts($selectedTypes, $selectedCompanyIds, $includeNullCompany);
$skipAdminUser = false;
$this->line('');
$this->line(' Items that would be affected:');
foreach ($counts as $type => $count) {
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
}
if (in_array('users', $selectedTypes)) {
$userInScope = $this->buildUserQuery($selectedCompanyIds, $includeNullCompany)
->where('users.id', $admin->id)
->exists();
if ($userInScope) {
$skipAdminUser = true;
$counts['users'] = max(0, ($counts['users'] ?? 0) - 1);
warning(" Your user ({$admin->username}) is within the selected scope and will be skipped during user deletion.");
}
}
$this->line('');
// Step 5: Hard delete, soft delete, or no delete?
$deleteType = select(
label: 'How should items be deleted?',
options: [
'soft' => 'Soft delete — items moved to trash (recoverable)',
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
'none' => 'No delete — check in only, items remain in inventory',
],
default: 'soft',
);
// Step 6: Send checkin notifications? (not applicable to users or consumables)
$notifiableTypes = array_intersect($selectedTypes, ['assets', 'licenses', 'accessories', 'components']);
$sendNotifications = false;
if (! empty($notifiableTypes)) {
$sendNotifications = confirm(
label: 'Should we send checkin notifications?',
default: true,
hint: 'Applies to: '.implode(', ', $notifiableTypes).'. Users and consumables are excluded.',
);
}
// Step 7: Clear related action_logs?
$clearLogs = confirm(
label: 'Should we clear related action logs?',
default: false,
hint: 'This removes all history for affected items, as if the data never existed.',
);
// Step 8: Delete associated files?
$deleteFiles = false;
if ($deleteType !== 'none') {
$deleteFiles = confirm(
label: 'Should we also delete associated image and upload files?',
default: $deleteType === 'hard',
hint: 'Permanently removes images, avatars, signatures, EULAs, and action log uploads from disk.',
);
}
// Step 9: Delete the companies themselves?
$deleteCompanyType = 'keep';
if (! empty($selectedCompanyIds)) {
$deleteCompanyType = select(
label: 'Should the selected companies also be deleted?',
options: [
'keep' => 'Keep — do not delete the companies',
'soft' => 'Soft delete — companies moved to trash (recoverable)',
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
],
default: 'keep',
);
}
// Step 10: Backup first?
$doBackup = confirm(
label: 'Should we run a backup before proceeding?',
default: true,
hint: 'Strongly recommended. Saved as backup-before-bulk-delete-cli-[datetime].zip',
);
// Step 11: Summary + final confirmation
$this->line('');
$this->line(' ════════════════════════════════════════════════════');
$this->line(' SUMMARY OF ACTIONS');
$this->line(' ════════════════════════════════════════════════════');
$this->line(" Admin user: {$admin->first_name} {$admin->last_name} ({$admin->username})");
$this->line(' Companies: '.implode(', ', $selectedCompanyNames));
$this->line(' Item types: '.implode(', ', $selectedTypes));
$this->line(" Delete mode: {$deleteType}");
$this->line(' Notifications: '.($sendNotifications ? 'Yes' : 'No'));
$this->line(' Clear logs: '.($clearLogs ? 'Yes' : 'No'));
$this->line(' Delete files: '.($deleteFiles ? 'Yes' : 'No'));
$this->line(' Delete companies: '.($deleteCompanyType === 'keep' ? 'No' : ucfirst($deleteCompanyType).' delete'));
$this->line(' Backup first: '.($doBackup ? 'Yes' : 'No'));
$this->line(' Dry run: '.($dryRun ? 'Yes' : 'No'));
$this->line('');
$this->line(' Items to be processed:');
foreach ($counts as $type => $count) {
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
}
if ($skipAdminUser) {
$this->line(' * Your user account will be skipped during user deletion.');
}
$this->line(' ════════════════════════════════════════════════════');
$this->line('');
// Step 10.5: Email report?
$sendEmailReport = false;
if ($admin->email) {
$sendEmailReport = confirm(
label: "Send an email report to {$admin->email}?",
default: false,
hint: 'A summary of all '.($dryRun ? 'would-be ' : '').'actions will be emailed to you.',
);
}
if (! $dryRun) {
$confirmed = confirm(
label: 'Are you sure you want to proceed? This cannot be undone.',
default: false,
);
if (! $confirmed) {
info('Aborted. No changes were made.');
return 0;
}
}
// Run backup if requested
if ($doBackup && ! $dryRun) {
$backupFilename = 'backup-before-bulk-delete-cli-'.now()->format('Y-m-d-H-i-s');
info("Running backup ({$backupFilename}.zip)...");
$result = $this->callSilently('snipeit:backup', ['--filename' => $backupFilename]);
if ($result === 0) {
info("Backup completed: {$backupFilename}.zip");
} else {
warning("Backup may have failed (exit code {$result}). Proceeding anyway.");
}
}
// Step 11: Execute with progress bar
$totalItems = array_sum($counts);
$bar = $this->output->createProgressBar($totalItems > 0 ? $totalItems : 1);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
$bar->setMessage('Starting...');
$bar->start();
foreach ($selectedTypes as $type) {
match ($type) {
'assets' => $this->processAssets($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
'licenses' => $this->processLicenses($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
'accessories' => $this->processAccessories($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
'components' => $this->processComponents($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
'consumables' => $this->processConsumables($selectedCompanyIds, $includeNullCompany, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
'users' => $this->processUsers($selectedCompanyIds, $includeNullCompany, $admin, $skipAdminUser, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
};
}
$bar->setMessage('Done.');
$bar->finish();
$this->line('');
$this->line('');
// Delete companies if requested
if ($deleteCompanyType !== 'keep' && ! empty($selectedCompanyIds)) {
$companies = Company::whereIn('id', $selectedCompanyIds)->get();
foreach ($companies as $company) {
if ($dryRun) {
$this->line(" [dry-run] Would {$deleteCompanyType}-delete company {$company->name}");
$this->reportLines[] = "Would {$deleteCompanyType}-delete company {$company->name}";
} else {
if ($deleteCompanyType === 'soft') {
$company->delete();
} else {
$company->forceDelete();
}
// Remove any remaining pivot associations (e.g. the admin user who was
// skipped during user processing but is still a member of this company)
DB::table('company_user')->where('company_id', $company->id)->delete();
$this->reportLines[] = ucfirst($deleteCompanyType)."-deleted company {$company->name}";
}
}
}
if ($dryRun) {
warning('Dry run complete — no changes were made.');
} else {
info('All actions completed successfully.');
}
if ($sendEmailReport && $admin->email) {
Mail::to($admin->email)->send(new BulkDeleteReportMail(
admin: $admin,
dryRun: $dryRun,
companyNames: $selectedCompanyNames,
selectedTypes: $selectedTypes,
deleteType: $deleteType,
reportLines: $this->reportLines,
runAt: now(),
));
info("Report sent to {$admin->email}.");
}
return 0;
}
private function getCounts(array $types, array $companyIds, bool $includeNull): array
{
$counts = [];
if (in_array('assets', $types)) {
$counts['assets'] = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->count();
}
if (in_array('licenses', $types)) {
$counts['licenses'] = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->count();
}
if (in_array('accessories', $types)) {
$counts['accessories'] = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->count();
}
if (in_array('components', $types)) {
$counts['components'] = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->count();
}
if (in_array('consumables', $types)) {
$counts['consumables'] = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->count();
}
if (in_array('users', $types)) {
$counts['users'] = $this->buildUserQuery($companyIds, $includeNull)->count();
}
return $counts;
}
private function buildCompanyQuery(Builder $query, array $companyIds, bool $includeNull): Builder
{
return $query->where(function (Builder $q) use ($companyIds, $includeNull) {
if (! empty($companyIds)) {
$q->whereIn('company_id', $companyIds);
}
if ($includeNull) {
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
$q->{$method}('company_id');
}
});
}
private function buildUserQuery(array $companyIds, bool $includeNull): Builder
{
return User::query()
->where('activated', 1)
->where(function (Builder $q) use ($companyIds, $includeNull) {
if (! empty($companyIds)) {
$q->whereIn('company_id', $companyIds);
}
if ($includeNull) {
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
$q->{$method}('company_id');
}
});
}
private function processAssets(
array $companyIds,
bool $includeNull,
bool $sendNotifications,
User $admin,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$assets = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->get();
foreach ($assets as $asset) {
$bar->setMessage("Assets: {$asset->asset_tag}");
if ($asset->assignedTo) {
if ($dryRun) {
$this->line(" [dry-run] Would check in asset {$asset->asset_tag} from {$asset->assignedTo->name}");
$this->reportLines[] = "Would check in asset {$asset->asset_tag} (assigned to {$asset->assignedTo->name})";
} else {
$target = $asset->assignedTo;
$checkinAt = now()->format('Y-m-d H:i:s');
$originalValues = $asset->getRawOriginal();
if ($sendNotifications) {
event(new CheckoutableCheckedIn($asset, $target, $admin, self::CHECKIN_NOTE, $checkinAt, $originalValues));
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
} else {
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
$asset->logCheckin($target, self::CHECKIN_NOTE, $checkinAt, $originalValues);
}
$this->reportLines[] = "Checked in asset {$asset->asset_tag} from {$target->name}";
$asset->licenseseats()->update(['assigned_to' => null]);
CheckoutAcceptance::where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->whereNull('accepted_at')
->whereNull('declined_at')
->forceDelete();
}
}
if (! $dryRun) {
// Collect action log file paths before logs may be cleared
$actionLogPaths = $deleteFiles
? $asset->assetlog()->whereNotNull('filename')->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
// Delete checkout acceptance files, then hard-remove all acceptances
if ($deleteFiles) {
CheckoutAcceptance::where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->get()
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
}
CheckoutAcceptance::where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->forceDelete();
// Hard-delete-only cleanup: maintenance records, accessory checkouts to this
// asset, and any other assets that were assigned to this one
$maintenanceImages = [];
if ($deleteType === 'hard') {
if ($deleteFiles) {
$maintenanceImages = $asset->maintenances()
->whereNotNull('image')
->pluck('image')
->toArray();
}
$asset->maintenances()->forceDelete();
AccessoryCheckout::where('assigned_to', $asset->id)
->where('assigned_type', Asset::class)
->delete();
DB::table('assets')
->where('assigned_to', $asset->id)
->where('assigned_type', Asset::class)
->update(['assigned_to' => null, 'assigned_type' => null]);
}
match ($deleteType) {
'soft' => $asset->delete(),
'hard' => $asset->forceDelete(),
default => null,
};
if ($deleteType !== 'none') {
$this->reportLines[] = ucfirst($deleteType)."-deleted asset {$asset->asset_tag}";
}
if ($clearLogs) {
$asset->assetlog()->forceDelete();
}
if ($deleteFiles) {
if ($asset->image) {
$this->deleteStorageFile('public', app('assets_upload_path').$asset->image);
}
foreach ($maintenanceImages as $img) {
$this->deleteStorageFile('public', app('maintenances_upload_path').$img);
}
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete asset {$asset->asset_tag}");
$this->reportLines[] = "Would {$deleteType}-delete asset {$asset->asset_tag}";
}
$bar->advance();
}
}
private function processLicenses(
array $companyIds,
bool $includeNull,
bool $sendNotifications,
User $admin,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$licenses = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->get();
foreach ($licenses as $license) {
$bar->setMessage("Licenses: {$license->name}");
$seats = LicenseSeat::where('license_id', $license->id)
->where(fn ($q) => $q->whereNotNull('assigned_to')->orWhereNotNull('asset_id'))
->get();
foreach ($seats as $seat) {
$target = $seat->assigned_to ? $seat->user : $seat->asset;
if ($dryRun) {
$this->line(" [dry-run] Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown'));
$this->reportLines[] = "Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
} else {
$seat->assigned_to = null;
$seat->asset_id = null;
$seat->save();
$this->reportLines[] = "Checked in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
if ($target) {
if ($sendNotifications) {
event(new CheckoutableCheckedIn($seat, $target, $admin, self::CHECKIN_NOTE));
} else {
$seat->logCheckin($target, self::CHECKIN_NOTE);
}
}
}
}
if (! $dryRun) {
// Collect action log file paths before logs may be cleared
$actionLogPaths = $deleteFiles
? $license->assetlog()->whereNotNull('filename')->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
if ($deleteType === 'soft') {
$license->licenseseats()->delete();
$license->delete();
$this->reportLines[] = "Soft-deleted license {$license->name}";
} elseif ($deleteType === 'hard') {
$seatIds = $license->licenseseats()->pluck('id');
if ($deleteFiles) {
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
->whereIn('checkoutable_id', $seatIds)
->get()
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
}
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
->whereIn('checkoutable_id', $seatIds)
->forceDelete();
$license->licenseseats()->forceDelete();
DB::table('kits_licenses')->where('license_id', $license->id)->delete();
$license->forceDelete();
$this->reportLines[] = "Hard-deleted license {$license->name}";
}
if ($clearLogs) {
$license->assetlog()->forceDelete();
}
if ($deleteFiles) {
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete license {$license->name}");
$this->reportLines[] = "Would {$deleteType}-delete license {$license->name}";
}
$bar->advance();
}
}
private function processAccessories(
array $companyIds,
bool $includeNull,
bool $sendNotifications,
User $admin,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$accessories = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->get();
foreach ($accessories as $accessory) {
$bar->setMessage("Accessories: {$accessory->name}");
$checkouts = AccessoryCheckout::where('accessory_id', $accessory->id)->get();
foreach ($checkouts as $checkout) {
$target = $checkout->assignedTo;
if ($dryRun) {
$this->line(" [dry-run] Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown'));
$this->reportLines[] = "Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
} else {
$checkinAt = now()->format('Y-m-d H:i:s');
$checkout->delete();
$this->reportLines[] = "Checked in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
if ($target) {
if ($sendNotifications) {
event(new CheckoutableCheckedIn($accessory, $target, $admin, self::CHECKIN_NOTE, $checkinAt));
} else {
$accessory->logCheckin($target, self::CHECKIN_NOTE, $checkinAt);
}
}
}
}
if (! $dryRun) {
// Collect action log file paths before logs may be cleared
$actionLogPaths = $deleteFiles
? $accessory->assetlog()->whereNotNull('filename')->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
if ($clearLogs) {
$accessory->assetlog()->forceDelete();
}
if ($deleteType === 'hard') {
DB::table('kits_accessories')->where('accessory_id', $accessory->id)->delete();
}
match ($deleteType) {
'soft' => $accessory->delete(),
'hard' => $accessory->forceDelete(),
default => null,
};
if ($deleteType !== 'none') {
$this->reportLines[] = ucfirst($deleteType)."-deleted accessory {$accessory->name}";
}
if ($deleteFiles) {
if ($accessory->image) {
$this->deleteStorageFile('public', app('accessories_upload_path').$accessory->image);
}
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete accessory {$accessory->name}");
$this->reportLines[] = "Would {$deleteType}-delete accessory {$accessory->name}";
}
$bar->advance();
}
}
private function processComponents(
array $companyIds,
bool $includeNull,
bool $sendNotifications,
User $admin,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$components = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->get();
foreach ($components as $component) {
$bar->setMessage("Components: {$component->name}");
$assignments = DB::table('components_assets')
->where('component_id', $component->id)
->get();
foreach ($assignments as $assignment) {
$asset = Asset::find($assignment->asset_id);
if ($dryRun) {
$this->line(" [dry-run] Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown'));
$this->reportLines[] = "Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
} else {
$checkinAt = now()->format('Y-m-d H:i:s');
DB::table('components_assets')->where('id', $assignment->id)->delete();
$this->reportLines[] = "Checked in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
if ($asset) {
if ($sendNotifications) {
event(new CheckoutableCheckedIn($component, $asset, $admin, self::CHECKIN_NOTE, $checkinAt));
} else {
$component->logCheckin($asset, self::CHECKIN_NOTE, $checkinAt);
}
}
}
}
if (! $dryRun) {
// Collect action log file paths before logs may be cleared
$actionLogPaths = $deleteFiles
? $component->assetlog()->whereNotNull('filename')->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
if ($clearLogs) {
$component->assetlog()->forceDelete();
}
match ($deleteType) {
'soft' => $component->delete(),
'hard' => $component->forceDelete(),
default => null,
};
if ($deleteType !== 'none') {
$this->reportLines[] = ucfirst($deleteType)."-deleted component {$component->name}";
}
if ($deleteFiles) {
if ($component->image) {
$this->deleteStorageFile('public', app('components_upload_path').$component->image);
}
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete component {$component->name}");
$this->reportLines[] = "Would {$deleteType}-delete component {$component->name}";
}
$bar->advance();
}
}
private function processConsumables(
array $companyIds,
bool $includeNull,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$consumables = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->get();
foreach ($consumables as $consumable) {
$bar->setMessage("Consumables: {$consumable->name}");
if (! $dryRun) {
// Collect action log file paths before logs may be cleared
$actionLogPaths = $deleteFiles
? $consumable->assetlog()->whereNotNull('filename')->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
if ($clearLogs) {
$consumable->assetlog()->forceDelete();
}
if ($deleteType === 'hard') {
DB::table('kits_consumables')->where('consumable_id', $consumable->id)->delete();
}
match ($deleteType) {
'soft' => $consumable->delete(),
'hard' => $consumable->forceDelete(),
default => null,
};
if ($deleteType !== 'none') {
$this->reportLines[] = ucfirst($deleteType)."-deleted consumable {$consumable->name}";
}
if ($deleteFiles) {
if ($consumable->image) {
$this->deleteStorageFile('public', app('consumables_upload_path').$consumable->image);
}
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete consumable {$consumable->name}");
$this->reportLines[] = "Would {$deleteType}-delete consumable {$consumable->name}";
}
$bar->advance();
}
}
private function processUsers(
array $companyIds,
bool $includeNull,
User $admin,
bool $skipAdminUser,
bool $dryRun,
string $deleteType,
bool $clearLogs,
bool $deleteFiles,
ProgressBar $bar,
): void {
$users = $this->buildUserQuery($companyIds, $includeNull)->get();
foreach ($users as $user) {
if ($skipAdminUser && $user->id === $admin->id) {
continue;
}
$bar->setMessage("Users: {$user->username}");
// If real companies were selected, check whether this user also belongs to
// companies outside the selected scope. If so, only remove the selected-company
// associations and skip full deletion to avoid orphaning them from their other companies.
if (! empty($companyIds)) {
$allUserCompanyIds = array_unique(array_filter(array_merge(
$user->companies()->pluck('companies.id')->toArray(),
$user->company_id ? [$user->company_id] : [],
)));
$outsideCompanyIds = array_values(array_diff($allUserCompanyIds, $companyIds));
if (! empty($outsideCompanyIds)) {
$outsideNames = Company::whereIn('id', $outsideCompanyIds)->pluck('name')->implode(', ');
if ($dryRun) {
$this->line(" [dry-run] Would partially disassociate user {$user->username} (also belongs to: {$outsideNames})");
$this->reportLines[] = "Would partially disassociate user {$user->username} — also belongs to: {$outsideNames}";
} else {
$user->companies()->detach($companyIds);
warning(" Skipped full deletion of {$user->username}: they also belong to {$outsideNames}. Removed selected company associations only.");
$this->reportLines[] = "Partially disassociated user {$user->username} — also belongs to: {$outsideNames}. Full deletion skipped.";
}
$bar->advance();
continue;
}
}
if (! $dryRun) {
// Collect file paths and acceptance records before deleting pivot data
$acceptancesToDelete = $deleteFiles
? CheckoutAcceptance::where('assigned_to_id', $user->id)->get()
: collect();
$actionLogPaths = $deleteFiles
? Actionlog::where('item_type', User::class)
->where('item_id', $user->id)
->where('action_type', 'uploaded')
->whereNotNull('filename')
->get()
->map(fn (Actionlog $log) => $log->uploads_file_path())
->filter()
->values()
->toArray()
: [];
// Clear pivot/assignment data that will orphan on deletion
LicenseSeat::where('assigned_to', $user->id)->update(['assigned_to' => null]);
AccessoryCheckout::where('assigned_to', $user->id)
->where('assigned_type', User::class)
->delete();
DB::table('consumables_users')->where('assigned_to', $user->id)->delete();
CheckoutAcceptance::where('assigned_to_id', $user->id)->forceDelete();
if ($deleteType === 'hard') {
DB::table('company_user')->where('user_id', $user->id)->delete();
}
if ($clearLogs) {
$user->userlog()->forceDelete();
}
match ($deleteType) {
'soft' => $user->delete(),
'hard' => $user->forceDelete(),
default => null,
};
if ($deleteType !== 'none') {
$this->reportLines[] = ucfirst($deleteType)."-deleted user {$user->username}";
}
if ($deleteFiles) {
if ($user->avatar) {
$this->deleteStorageFile('public', app('users_upload_path').$user->avatar);
}
$acceptancesToDelete->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
foreach ($actionLogPaths as $path) {
$this->deleteStorageFile('local', $path);
}
}
} elseif ($deleteType !== 'none') {
$this->line(" [dry-run] Would {$deleteType}-delete user {$user->username}");
$this->reportLines[] = "Would {$deleteType}-delete user {$user->username}";
}
$bar->advance();
}
}
private function deleteStorageFile(string $disk, ?string $path): void
{
if (! $path) {
return;
}
try {
$storage = $disk === 'public'
? Storage::disk('public')
: Storage::disk(config('filesystems.default'));
if ($storage->exists($path)) {
$storage->delete($path);
}
} catch (\Exception $e) {
Log::warning("Could not delete file {$path}: ".$e->getMessage());
}
}
private function deleteAcceptanceFiles(CheckoutAcceptance $acceptance): void
{
if ($acceptance->signature_filename) {
$this->deleteStorageFile('local', 'private_uploads/signatures/'.$acceptance->signature_filename);
}
if ($acceptance->stored_eula_file) {
$this->deleteStorageFile('local', 'private_uploads/eula-pdfs/'.$acceptance->stored_eula_file);
}
}
}