updated design
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user