Files
Helpddesk/Modules/Helpdesk/Services/HelpdeskSyncService.php
T

605 lines
18 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
final class HelpdeskSyncService
{
public const SCHEMA_VERSION = 1;
/**
* Settings/configuration tables are intentionally separate so a host can
* copy the Helpdesk configuration without copying operational tickets.
*
* @var list<string>
*/
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<string>
*/
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<string>
*/
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<string> $tables
* @return list<string>
*/
private function existingTables(array $tables): array
{
return array_values(array_filter(
$tables,
static fn (string $table): bool => Schema::hasTable($table),
));
}
/**
* @return list<array<string, mixed>>
*/
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<string, list<array<string, mixed>>> $tables
* @return list<array<string, mixed>>
*/
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<array<string, mixed>> $users
* @return array<int, int>
*/
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<array<string, mixed>> $rows
* @param array<int, int> $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<string, mixed> $row
* @param array<int, int> $userMap
* @return array<string, mixed>
*/
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<string, mixed> $row
* @return array<string, mixed>
*/
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<array<string, mixed>> $attachments
* @return list<array<string, mixed>>
*/
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<array<string, mixed>> $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;
}
}