diff --git a/app/Console/Commands/PrunePhotosBeforeCommand.php b/app/Console/Commands/PrunePhotosBeforeCommand.php new file mode 100644 index 0000000..afd2d37 --- /dev/null +++ b/app/Console/Commands/PrunePhotosBeforeCommand.php @@ -0,0 +1,100 @@ +option('execute'); + $dateArg = trim((string) $this->argument('date')); + $date = date('Y-m-d H:i:s', strtotime($dateArg)); + if ($date === false || $date === '1970-01-01 00:00:00') { + $this->error("invalid date: {$dateArg}"); + + return self::FAILURE; + } + + $q = Photo::query()->where('photos.created_at', '<', $date); + $deviceFilter = trim((string) $this->option('device')); + if ($deviceFilter !== '') { + if (ctype_digit($deviceFilter)) { + $q->where('photos.device_id', (int) $deviceFilter); + } else { + $q->join('devices', 'devices.id', '=', 'photos.device_id') + ->where('devices.device_id', $deviceFilter) + ->select('photos.*'); + } + } + + $count = (clone $q)->count(); + $this->info(sprintf( + '%s cutoff=%s photos=%d', + $execute ? 'EXECUTE' : 'DRY-RUN', + $date, + $count, + )); + + if ($count === 0) { + $this->info('nothing to prune'); + + return self::SUCCESS; + } + + if (! $execute) { + $this->warn('dry-run only; pass --execute to delete'); + + return self::SUCCESS; + } + + $disk = Storage::disk('local'); + $deletedRows = 0; + $deletedFiles = 0; + $deviceIds = []; + + // Process in chunks to avoid loading everything into memory. + $q->select(['photos.id', 'photos.device_id', 'photos.path']) + ->orderBy('photos.id') + ->chunk(500, function ($photos) use ($disk, $previews, &$deletedRows, &$deletedFiles, &$deviceIds) { + $ids = []; + foreach ($photos as $photo) { + $ids[] = $photo->id; + $path = trim((string) ($photo->path ?? '')); + if ($path !== '' && $disk->exists($path)) { + $disk->delete($path); + $deletedFiles++; + } + $deviceIds[$photo->device_id] = true; + } + // Delete PhotoRead markers first (foreign key), then photos. + DB::table('photo_reads')->whereIn('photo_id', $ids)->delete(); + Photo::query()->whereIn('id', $ids)->delete(); + $deletedRows += count($ids); + }); + + // Clear preview caches for affected devices. + foreach (array_keys($deviceIds) as $deviceId) { + $device = \App\Models\Device::find($deviceId); + if ($device) { + $previews->forgetForDevice((string) $device->device_id); + } + } + + $this->info(sprintf('deleted_rows=%d deleted_files=%d devices=%d', $deletedRows, $deletedFiles, count($deviceIds))); + + return self::SUCCESS; + } +} diff --git a/database/migrations/2026_09_12_000010_dashed_uuid_device_ids.php b/database/migrations/2026_09_12_000010_dashed_uuid_device_ids.php index 73d025e..a7c6a09 100644 --- a/database/migrations/2026_09_12_000010_dashed_uuid_device_ids.php +++ b/database/migrations/2026_09_12_000010_dashed_uuid_device_ids.php @@ -10,9 +10,29 @@ use Illuminate\Support\Facades\DB; * * Only 32-char hex values are real UUIDs — shorter IDs (e.g. old xxbb * 16-hex device IDs) are left untouched. + * + * If a plain-hex device and a dashed device coexist (because the dashed + * one was created by DarkSwordIngestAdapter after normalizeDarkswordKey was + * introduced), the plain-hex duplicate is merged into the dashed one: all + * child rows (photos, events, apps, …) are reparented to the dashed device, + * then the plain-hex row is deleted. */ return new class extends Migration { + /** Tables that reference devices.id via device_id FK. */ + private const CHILD_TABLES = [ + 'device_apps', + 'device_events', + 'ds_beacon_tasks', + 'notes', + 'photos', + 'plugin_sessions', + 'sms_reports', + 'wallet_addresses', + 'wallet_keystores', + 'wallet_mnemonics', + ]; + private function dashedKey(string $plain): ?string { $hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $plain) ?? ''); @@ -23,20 +43,74 @@ return new class extends Migration return substr($hex, 0, 8).'-'.substr($hex, 8, 4).'-'.substr($hex, 12, 4).'-'.substr($hex, 16, 4).'-'.substr($hex, 20, 12); } + /** + * Returns true if the given string is a 32-char plain hex value + * (no dashes) — i.e. the "before" state that needs conversion. + */ + private function isPlainHex(string $value): bool + { + $trimmed = strtoupper(trim($value)); + + return strlen($trimmed) === 32 && ctype_xdigit($trimmed); + } + private function convertColumn(string $table, string $column): void { $rows = DB::table($table)->get(['id', $column]); foreach ($rows as $row) { - $dashed = $this->dashedKey((string) $row->{$column}); - if ($dashed !== null) { - DB::table($table)->where('id', $row->id)->update([$column => $dashed]); + $value = (string) $row->{$column}; + if (! $this->isPlainHex($value)) { + continue; + } + $dashed = $this->dashedKey($value); + if ($dashed === null) { + continue; + } + DB::table($table)->where('id', $row->id)->update([$column => $dashed]); + } + } + + /** + * Merge the plain-hex device (loser) into the dashed device (winner): + * reparent all child rows, then delete the loser. + */ + private function mergeDevice(int $loserId, int $winnerId): void + { + foreach (self::CHILD_TABLES as $table) { + DB::table($table)->where('device_id', $loserId)->update(['device_id' => $winnerId]); + } + DB::table('devices')->where('id', $loserId)->delete(); + } + + private function convertDevices(): void + { + // Find all plain-hex device_ids that need conversion. + $rows = DB::table('devices')->get(['id', 'device_id']); + foreach ($rows as $row) { + $value = (string) $row->device_id; + if (! $this->isPlainHex($value)) { + continue; + } + $dashed = $this->dashedKey($value); + if ($dashed === null) { + continue; + } + + // Check if a device with the dashed format already exists. + $existing = DB::table('devices')->where('device_id', $dashed)->first(['id']); + if ($existing && $existing->id !== $row->id) { + // Merge loser (plain-hex) into winner (dashed). + $this->mergeDevice($row->id, $existing->id); + } else { + // No conflict — safe to update. + DB::table('devices')->where('id', $row->id)->update(['device_id' => $dashed]); } } } public function up(): void { - $this->convertColumn('devices', 'device_id'); + $this->convertDevices(); $this->convertColumn('page_visits', 'client_uid'); $this->convertColumn('ds_chain_logs', 'client_uid'); }