606 lines
18 KiB
PHP
606 lines
18 KiB
PHP
<?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;
|
|
use Throwable;
|
|
|
|
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;
|
|
}
|
|
}
|