updated design
This commit is contained in:
@@ -7,6 +7,26 @@ return [
|
||||
'middleware' => ['web', 'auth'],
|
||||
'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'),
|
||||
'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240),
|
||||
|
||||
'sync' => [
|
||||
// URL of the other Helpdesk host, e.g. https://helpdesk-remote.example.com
|
||||
'remote_url' => env('HELPDESK_SYNC_REMOTE_URL'),
|
||||
|
||||
// Bearer token this host sends to the configured remote host.
|
||||
'remote_token' => env('HELPDESK_SYNC_REMOTE_TOKEN'),
|
||||
|
||||
// Bearer token this host accepts on /api/helpdesk/sync/*.
|
||||
// Leave blank to disable inbound remote sync completely.
|
||||
'accept_token' => env('HELPDESK_SYNC_ACCEPT_TOKEN'),
|
||||
|
||||
'include_attachments' => env('HELPDESK_SYNC_INCLUDE_ATTACHMENTS', true),
|
||||
'max_attachment_bytes' => (int) env('HELPDESK_SYNC_MAX_ATTACHMENT_BYTES', 10485760),
|
||||
'connect_timeout' => (int) env('HELPDESK_SYNC_CONNECT_TIMEOUT', 10),
|
||||
'timeout' => (int) env('HELPDESK_SYNC_TIMEOUT', 120),
|
||||
'retries' => (int) env('HELPDESK_SYNC_RETRIES', 2),
|
||||
'retry_delay_ms' => (int) env('HELPDESK_SYNC_RETRY_DELAY_MS', 500),
|
||||
],
|
||||
|
||||
'allowed_extensions' => [
|
||||
'md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'log', 'zip',
|
||||
'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
|
||||
@@ -24,7 +44,6 @@ return [
|
||||
'blocked_extensions' => ['exe', 'dll', 'com', 'scr', 'msi', 'app', 'dmg', 'pkg', 'jar', 'war'],
|
||||
'virus_scanner' => env('HELPDESK_VIRUS_SCANNER', null),
|
||||
|
||||
// Backwards-compatible alias for older module code/tests that still read this key.
|
||||
'allowed_mimes' => [],
|
||||
'default_status_slug' => 'new',
|
||||
'closed_status_slug' => 'closed',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\Helpdesk\Services\HelpdeskSyncService;
|
||||
use Throwable;
|
||||
|
||||
final class HelpdeskSyncCommand extends Command
|
||||
{
|
||||
protected $signature = 'helpdesk:sync
|
||||
{direction=pull : pull or push}
|
||||
{--url= : Override HELPDESK_SYNC_REMOTE_URL}
|
||||
{--token= : Override HELPDESK_SYNC_REMOTE_TOKEN}
|
||||
{--settings-only : Sync Helpdesk settings/configuration only}
|
||||
{--without-files : Do not transfer attachment file contents}
|
||||
{--mirror : Delete local/remote Helpdesk rows that are absent from the source snapshot}';
|
||||
|
||||
protected $description = 'Synchronise Helpdesk data and settings with another Helpdesk host';
|
||||
|
||||
public function handle(HelpdeskSyncService $sync): int
|
||||
{
|
||||
$direction = strtolower((string) $this->argument('direction'));
|
||||
|
||||
if (! in_array($direction, ['push', 'pull'], true)) {
|
||||
$this->error('Direction must be "push" or "pull".');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$url = trim((string) ($this->option('url') ?: config('helpdesk.sync.remote_url')));
|
||||
$token = trim((string) ($this->option('token') ?: config('helpdesk.sync.remote_token')));
|
||||
|
||||
if ($url === '' || $token === '') {
|
||||
$this->error('Configure HELPDESK_SYNC_REMOTE_URL and HELPDESK_SYNC_REMOTE_TOKEN, or pass --url and --token.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$settingsOnly = (bool) $this->option('settings-only');
|
||||
$includeFiles = ! (bool) $this->option('without-files');
|
||||
$mirror = (bool) $this->option('mirror');
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s Helpdesk %s %s',
|
||||
ucfirst($direction),
|
||||
$settingsOnly ? 'settings' : 'settings + data',
|
||||
$direction === 'push' ? 'to '.$url : 'from '.$url,
|
||||
));
|
||||
|
||||
if ($mirror) {
|
||||
$this->warn('Mirror mode is enabled. Destination Helpdesk rows absent from the source can be deleted.');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $direction === 'push'
|
||||
? $sync->push($url, $token, $settingsOnly, $includeFiles, $mirror)
|
||||
: $sync->pull($url, $token, $settingsOnly, $includeFiles, $mirror);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
collect($result)->map(fn ($value, $key): array => [
|
||||
(string) $key,
|
||||
is_scalar($value) || $value === null ? (string) $value : json_encode($value, JSON_UNESCAPED_SLASHES),
|
||||
])->values()->all(),
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# Helpdesk Remote Sync
|
||||
|
||||
The Helpdesk module can synchronise its configuration and operational data between two Helpdesk installations.
|
||||
|
||||
## Configure both hosts
|
||||
|
||||
Use a long random shared token. You can use the same token in both directions or separate inbound/outbound tokens.
|
||||
|
||||
```dotenv
|
||||
HELPDESK_SYNC_REMOTE_URL=https://other-helpdesk.example.com
|
||||
HELPDESK_SYNC_REMOTE_TOKEN=replace-with-a-long-random-token
|
||||
HELPDESK_SYNC_ACCEPT_TOKEN=replace-with-a-long-random-token
|
||||
|
||||
HELPDESK_SYNC_INCLUDE_ATTACHMENTS=true
|
||||
HELPDESK_SYNC_MAX_ATTACHMENT_BYTES=10485760
|
||||
HELPDESK_SYNC_TIMEOUT=120
|
||||
```
|
||||
|
||||
After changing `.env`:
|
||||
|
||||
```bash
|
||||
php artisan optimize:clear
|
||||
```
|
||||
|
||||
## Check the remote endpoint
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
https://other-helpdesk.example.com/api/helpdesk/sync/status
|
||||
```
|
||||
|
||||
## Pull from the configured remote host
|
||||
|
||||
```bash
|
||||
php artisan helpdesk:sync pull
|
||||
```
|
||||
|
||||
## Push to the configured remote host
|
||||
|
||||
```bash
|
||||
php artisan helpdesk:sync push
|
||||
```
|
||||
|
||||
## Settings only
|
||||
|
||||
```bash
|
||||
php artisan helpdesk:sync pull --settings-only
|
||||
php artisan helpdesk:sync push --settings-only
|
||||
```
|
||||
|
||||
## Do not transfer attachment file contents
|
||||
|
||||
```bash
|
||||
php artisan helpdesk:sync pull --without-files
|
||||
```
|
||||
|
||||
Attachment database metadata still synchronises when Helpdesk data is included.
|
||||
|
||||
## Mirror mode
|
||||
|
||||
By default, sync is non-destructive: rows are inserted or updated but destination-only rows are retained.
|
||||
|
||||
To make the destination mirror the source:
|
||||
|
||||
```bash
|
||||
php artisan helpdesk:sync pull --mirror
|
||||
```
|
||||
|
||||
Use mirror mode carefully. It can delete Helpdesk records from the destination.
|
||||
|
||||
## User mapping
|
||||
|
||||
Helpdesk users are mapped by email address, not numeric user ID. `helpdesk_admin` and `helpdesk_agent` flags are synchronised. Password hashes are never copied. If a referenced user does not exist on the destination, a local user record is created with a random password.
|
||||
|
||||
## API endpoints
|
||||
|
||||
All sync endpoints require a Bearer token matching `HELPDESK_SYNC_ACCEPT_TOKEN`.
|
||||
|
||||
- `GET /api/helpdesk/sync/status`
|
||||
- `GET /api/helpdesk/sync/export`
|
||||
- `POST /api/helpdesk/sync/import`
|
||||
|
||||
The payload is versioned using `schema_version`.
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Helpdesk\Services\HelpdeskSyncService;
|
||||
use Throwable;
|
||||
|
||||
final class HelpdeskSyncController extends Controller
|
||||
{
|
||||
public function status(HelpdeskSyncService $sync): JsonResponse
|
||||
{
|
||||
return response()->json($sync->status());
|
||||
}
|
||||
|
||||
public function export(Request $request, HelpdeskSyncService $sync): JsonResponse
|
||||
{
|
||||
$settingsOnly = $request->boolean('settings_only');
|
||||
$includeFiles = ! $request->boolean('without_files');
|
||||
|
||||
return response()->json(
|
||||
$sync->snapshot(
|
||||
settingsOnly: $settingsOnly,
|
||||
includeFiles: $includeFiles,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function import(Request $request, HelpdeskSyncService $sync): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'snapshot' => ['required', 'array'],
|
||||
'mirror' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $sync->importSnapshot(
|
||||
$validated['snapshot'],
|
||||
mirror: (bool) ($validated['mirror'] ?? false),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class VerifyHelpdeskSyncToken
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$expected = (string) config('helpdesk.sync.accept_token', '');
|
||||
|
||||
if ($expected === '') {
|
||||
abort(503, 'Helpdesk remote sync is not enabled on this host.');
|
||||
}
|
||||
|
||||
$provided = (string) $request->bearerToken();
|
||||
|
||||
if ($provided === '' || ! hash_equals($expected, $provided)) {
|
||||
abort(401, 'Invalid Helpdesk sync token.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,23 @@ declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Helpdesk\Http\Controllers\GitWebhookController;
|
||||
use Modules\Helpdesk\Http\Controllers\HelpdeskSyncController;
|
||||
use Modules\Helpdesk\Http\Middleware\VerifyHelpdeskSyncToken;
|
||||
|
||||
Route::get('/status', static fn (): array => [
|
||||
'module' => 'helpdesk',
|
||||
'status' => 'ok',
|
||||
])->name('status');
|
||||
|
||||
Route::middleware(VerifyHelpdeskSyncToken::class)
|
||||
->prefix('sync')
|
||||
->name('sync.')
|
||||
->group(static function (): void {
|
||||
Route::get('/status', [HelpdeskSyncController::class, 'status'])->name('status');
|
||||
Route::get('/export', [HelpdeskSyncController::class, 'export'])->name('export');
|
||||
Route::post('/import', [HelpdeskSyncController::class, 'import'])->name('import');
|
||||
});
|
||||
|
||||
Route::post('/git/webhook/{repository}', GitWebhookController::class)
|
||||
->whereNumber('repository')
|
||||
->name('git.webhook');
|
||||
|
||||
@@ -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