*/ private const SETTINGS_TABLES = [ 'helpdesk_statuses', 'helpdesk_priorities', 'helpdesk_categories', 'helpdesk_teams', 'helpdesk_projects', 'helpdesk_products', 'helpdesk_tags', 'helpdesk_kanban_boards', 'helpdesk_kanban_board_statuses', 'helpdesk_workflow_templates', 'helpdesk_sla_policies', 'helpdesk_canned_responses', 'helpdesk_automation_rules', 'helpdesk_checklist_templates', 'helpdesk_git_repositories', 'helpdesk_category_user', ]; /** * Operational Helpdesk tables, in dependency/import order. * * @var list */ private const DATA_TABLES = [ 'helpdesk_tickets', 'helpdesk_comments', 'helpdesk_attachments', 'helpdesk_checklist_items', 'helpdesk_ticket_tag', 'helpdesk_ticket_time_logs', 'helpdesk_time_logs', 'helpdesk_ticket_sla_events', 'helpdesk_ticket_ratings', 'helpdesk_git_commits', 'helpdesk_git_webhook_logs', 'helpdesk_workflow_logs', ]; /** * Columns that point to App\Models\User. These are remapped using email * because user IDs can differ between the source and destination hosts. * * @var list */ private const USER_ID_COLUMNS = [ 'user_id', 'agent_id', 'owner_id', 'completed_by', 'deleted_by', 'created_by', 'updated_by', 'assigned_by', ]; public function status(): array { return [ 'ok' => true, 'module' => 'helpdesk', 'schema_version' => self::SCHEMA_VERSION, 'host' => config('app.url'), 'settings_tables' => count($this->existingTables(self::SETTINGS_TABLES)), 'data_tables' => count($this->existingTables(self::DATA_TABLES)), 'accepting_sync' => (string) config('helpdesk.sync.accept_token', '') !== '', 'attachments_enabled' => (bool) config('helpdesk.sync.include_attachments', true), ]; } public function snapshot(bool $settingsOnly = false, bool $includeFiles = true): array { $tables = $this->existingTables(array_merge( self::SETTINGS_TABLES, $settingsOnly ? [] : self::DATA_TABLES, )); $rowsByTable = []; foreach ($tables as $table) { $rowsByTable[$table] = $this->rows($table); } $users = $this->exportUsers($rowsByTable); $snapshot = [ 'schema_version' => self::SCHEMA_VERSION, 'generated_at' => now()->toIso8601String(), 'source' => [ 'url' => config('app.url'), 'name' => config('app.name'), ], 'settings_only' => $settingsOnly, 'users' => $users, 'tables' => $rowsByTable, 'files' => [], ]; if ( ! $settingsOnly && $includeFiles && (bool) config('helpdesk.sync.include_attachments', true) && isset($rowsByTable['helpdesk_attachments']) ) { $snapshot['files'] = $this->exportAttachmentFiles($rowsByTable['helpdesk_attachments']); } return $snapshot; } public function importSnapshot(array $snapshot, bool $mirror = false): array { $version = (int) ($snapshot['schema_version'] ?? 0); if ($version !== self::SCHEMA_VERSION) { throw new RuntimeException(sprintf( 'Unsupported Helpdesk sync schema version %d; expected %d.', $version, self::SCHEMA_VERSION, )); } if (! isset($snapshot['tables']) || ! is_array($snapshot['tables'])) { throw new RuntimeException('Invalid Helpdesk sync payload: tables are missing.'); } $result = [ 'tables' => 0, 'rows' => 0, 'users_mapped' => 0, 'files' => 0, 'mirror' => $mirror, ]; $userMap = $this->importUsers( is_array($snapshot['users'] ?? null) ? $snapshot['users'] : [] ); $result['users_mapped'] = count($userMap); Schema::disableForeignKeyConstraints(); try { DB::transaction(function () use ($snapshot, $userMap, $mirror, &$result): void { $allowed = array_merge(self::SETTINGS_TABLES, self::DATA_TABLES); foreach ($allowed as $table) { if (! Schema::hasTable($table)) { continue; } $rows = $snapshot['tables'][$table] ?? null; if (! is_array($rows)) { continue; } $this->importTable($table, $rows, $userMap, $mirror); $result['tables']++; $result['rows'] += count($rows); } }); } finally { Schema::enableForeignKeyConstraints(); } if (is_array($snapshot['files'] ?? null)) { $result['files'] = $this->importAttachmentFiles($snapshot['files']); } return $result; } public function push( string $url, string $token, bool $settingsOnly = false, bool $includeFiles = true, bool $mirror = false, ): array { $snapshot = $this->snapshot($settingsOnly, $includeFiles); $response = $this->client($token) ->post($this->endpoint($url, '/api/helpdesk/sync/import'), [ 'snapshot' => $snapshot, 'mirror' => $mirror, ]) ->throw() ->json(); if (! ($response['ok'] ?? false)) { throw new RuntimeException((string) ($response['message'] ?? 'Remote Helpdesk import failed.')); } return (array) ($response['result'] ?? []); } public function pull( string $url, string $token, bool $settingsOnly = false, bool $includeFiles = true, bool $mirror = false, ): array { $snapshot = $this->client($token) ->get($this->endpoint($url, '/api/helpdesk/sync/export'), [ 'settings_only' => $settingsOnly ? 1 : 0, 'without_files' => $includeFiles ? 0 : 1, ]) ->throw() ->json(); if (! is_array($snapshot)) { throw new RuntimeException('Remote Helpdesk returned an invalid snapshot.'); } return $this->importSnapshot($snapshot, $mirror); } private function client(string $token): PendingRequest { return Http::acceptJson() ->asJson() ->withToken($token) ->connectTimeout((int) config('helpdesk.sync.connect_timeout', 10)) ->timeout((int) config('helpdesk.sync.timeout', 120)) ->retry( (int) config('helpdesk.sync.retries', 2), (int) config('helpdesk.sync.retry_delay_ms', 500), ); } private function endpoint(string $baseUrl, string $path): string { return rtrim($baseUrl, '/').'/'.ltrim($path, '/'); } /** * @param list $tables * @return list */ private function existingTables(array $tables): array { return array_values(array_filter( $tables, static fn (string $table): bool => Schema::hasTable($table), )); } /** * @return list> */ private function rows(string $table): array { $query = DB::table($table); $columns = Schema::getColumnListing($table); if (in_array('id', $columns, true)) { $query->orderBy('id'); } return array_map( static fn (object $row): array => (array) $row, $query->get()->all(), ); } /** * @param array>> $tables * @return list> */ private function exportUsers(array $tables): array { if (! Schema::hasTable('users')) { return []; } $ids = []; foreach ($tables as $rows) { foreach ($rows as $row) { foreach (self::USER_ID_COLUMNS as $column) { if (isset($row[$column]) && is_numeric($row[$column])) { $ids[(int) $row[$column]] = true; } } } } $userColumns = Schema::getColumnListing('users'); $query = DB::table('users'); $hasAgent = in_array('helpdesk_agent', $userColumns, true); $hasAdmin = in_array('helpdesk_admin', $userColumns, true); $query->where(function ($query) use ($ids, $hasAgent, $hasAdmin): void { if ($ids !== []) { $query->whereIn('id', array_keys($ids)); } else { $query->whereRaw('1 = 0'); } if ($hasAgent) { $query->orWhere('helpdesk_agent', true); } if ($hasAdmin) { $query->orWhere('helpdesk_admin', true); } }); $select = array_values(array_intersect([ 'id', 'name', 'email', 'email_verified_at', 'helpdesk_agent', 'helpdesk_admin', ], $userColumns)); if ($select === []) { return []; } return array_map( static fn (object $user): array => (array) $user, $query->select($select)->orderBy('id')->get()->all(), ); } /** * @param list> $users * @return array */ private function importUsers(array $users): array { if (! Schema::hasTable('users')) { return []; } $columns = Schema::getColumnListing('users'); $map = []; foreach ($users as $source) { $sourceId = isset($source['id']) ? (int) $source['id'] : 0; $email = strtolower(trim((string) ($source['email'] ?? ''))); if ($sourceId < 1 || $email === '') { continue; } $target = DB::table('users')->whereRaw('LOWER(email) = ?', [$email])->first(); if ($target === null) { $insert = [ 'name' => (string) ($source['name'] ?? $email), 'email' => $email, 'password' => password_hash(Str::random(64), PASSWORD_BCRYPT), 'created_at' => now(), 'updated_at' => now(), ]; if (in_array('email_verified_at', $columns, true)) { $insert['email_verified_at'] = $source['email_verified_at'] ?? now(); } if (in_array('helpdesk_agent', $columns, true)) { $insert['helpdesk_agent'] = (bool) ($source['helpdesk_agent'] ?? false); } if (in_array('helpdesk_admin', $columns, true)) { $insert['helpdesk_admin'] = (bool) ($source['helpdesk_admin'] ?? false); } $targetId = (int) DB::table('users')->insertGetId( array_intersect_key($insert, array_flip($columns)) ); } else { $targetId = (int) $target->id; $updates = []; if (in_array('name', $columns, true) && isset($source['name'])) { $updates['name'] = $source['name']; } if (in_array('helpdesk_agent', $columns, true)) { $updates['helpdesk_agent'] = (bool) ($source['helpdesk_agent'] ?? false); } if (in_array('helpdesk_admin', $columns, true)) { $updates['helpdesk_admin'] = (bool) ($source['helpdesk_admin'] ?? false); } if (in_array('updated_at', $columns, true)) { $updates['updated_at'] = now(); } if ($updates !== []) { DB::table('users')->where('id', $targetId)->update($updates); } } $map[$sourceId] = $targetId; } return $map; } /** * @param list> $rows * @param array $userMap */ private function importTable(string $table, array $rows, array $userMap, bool $mirror): void { $columns = Schema::getColumnListing($table); $hasId = in_array('id', $columns, true); $sourceIds = []; foreach ($rows as $row) { if (! is_array($row)) { continue; } $row = array_intersect_key($row, array_flip($columns)); $row = $this->remapUserColumns($row, $userMap); if ($row === []) { continue; } if ($hasId && isset($row['id'])) { $id = (int) $row['id']; $sourceIds[] = $id; DB::table($table)->updateOrInsert(['id' => $id], $row); continue; } $key = $this->compositeKey($table, $row); DB::table($table)->updateOrInsert($key, $row); } if ($mirror && $hasId) { if ($sourceIds === []) { DB::table($table)->delete(); } else { DB::table($table)->whereNotIn('id', $sourceIds)->delete(); } } } /** * @param array $row * @param array $userMap * @return array */ private function remapUserColumns(array $row, array $userMap): array { foreach (self::USER_ID_COLUMNS as $column) { if (! array_key_exists($column, $row) || $row[$column] === null) { continue; } $sourceId = (int) $row[$column]; if (isset($userMap[$sourceId])) { $row[$column] = $userMap[$sourceId]; } } return $row; } /** * @param array $row * @return array */ private function compositeKey(string $table, array $row): array { $preferred = match ($table) { 'helpdesk_ticket_tag' => ['ticket_id', 'tag_id'], 'helpdesk_category_user' => ['category_id', 'user_id'], 'helpdesk_kanban_board_statuses' => ['board_id', 'status_id'], default => [], }; $key = array_intersect_key($row, array_flip($preferred)); if ($key !== []) { return $key; } if (isset($row['uuid'])) { return ['uuid' => $row['uuid']]; } if (isset($row['slug'])) { return ['slug' => $row['slug']]; } throw new RuntimeException("Cannot determine an import key for {$table}."); } /** * @param list> $attachments * @return list> */ private function exportAttachmentFiles(array $attachments): array { $files = []; $maxBytes = (int) config('helpdesk.sync.max_attachment_bytes', 10 * 1024 * 1024); foreach ($attachments as $attachment) { $disk = (string) ($attachment['disk'] ?? config('helpdesk.disk', 'local')); $path = (string) ($attachment['path'] ?? ''); if ($path === '' || ! Storage::disk($disk)->exists($path)) { continue; } $size = Storage::disk($disk)->size($path); if ($maxBytes > 0 && $size > $maxBytes) { continue; } $files[] = [ 'attachment_id' => (int) ($attachment['id'] ?? 0), 'disk' => $disk, 'path' => $path, 'size' => $size, 'sha256' => hash('sha256', Storage::disk($disk)->get($path)), 'contents_base64' => base64_encode(Storage::disk($disk)->get($path)), ]; } return $files; } /** * @param list> $files */ private function importAttachmentFiles(array $files): int { $written = 0; foreach ($files as $file) { $disk = (string) ($file['disk'] ?? config('helpdesk.disk', 'local')); $path = (string) ($file['path'] ?? ''); $encoded = (string) ($file['contents_base64'] ?? ''); if ($path === '' || $encoded === '') { continue; } $contents = base64_decode($encoded, true); if ($contents === false) { throw new RuntimeException("Invalid base64 attachment payload for {$path}."); } $expected = (string) ($file['sha256'] ?? ''); if ($expected !== '' && ! hash_equals($expected, hash('sha256', $contents))) { throw new RuntimeException("Attachment checksum mismatch for {$path}."); } Storage::disk($disk)->put($path, $contents); $written++; } return $written; } }