Add helpdesk import progress, category model, article metadata columns, and ticket pagination controls

This commit is contained in:
SitiWeb
2026-04-29 13:21:52 +02:00
parent 3c4572bb12
commit 01aa115a49
7 changed files with 234 additions and 33 deletions

View File

@@ -19,12 +19,28 @@ class ImportHelpdeskArticlesCommand extends Command
$limitOption = $this->option('limit'); $limitOption = $this->option('limit');
$limit = is_numeric($limitOption) ? (int) $limitOption : null; $limit = is_numeric($limitOption) ? (int) $limitOption : null;
$bar = null;
$result = $service->import( $result = $service->import(
(string) $this->option('base-url'), (string) $this->option('base-url'),
(bool) $this->option('dry-run'), (bool) $this->option('dry-run'),
$limit $limit,
function (int $processed, int $total, string $url, string $status) use (&$bar): void {
if ($bar === null) {
$bar = $this->output->createProgressBar($total);
$bar->start();
}
$bar->advance();
$bar->setMessage("{$status}: {$url}");
}
); );
if ($bar !== null) {
$bar->finish();
$this->newLine(2);
}
$this->info('Helpdesk import finished.'); $this->info('Helpdesk import finished.');
$this->table( $this->table(
['Metric', 'Value'], ['Metric', 'Value'],

View File

@@ -10,10 +10,17 @@ class TicketMonitor extends Component
{ {
use WithPagination; use WithPagination;
public int $perPage = 10;
public function updatedPerPage(): void
{
$this->resetPage();
}
public function render(AdminTicketService $service) public function render(AdminTicketService $service)
{ {
return view('livewire.admin.ticket-monitor', [ return view('livewire.admin.ticket-monitor', [
'tickets' => $service->paginateWithDecision(10), 'tickets' => $service->paginateWithDecision($this->perPage),
]); ]);
} }
} }

View File

@@ -4,11 +4,21 @@ namespace App\Models;
use App\Casts\VectorCast; use App\Casts\VectorCast;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
class Article extends Model class Article extends Model
{ {
protected $fillable = ['title', 'content', 'embedding']; protected $fillable = [
'title',
'content',
'embedding',
'source',
'source_url',
'source_article_id',
'category_id',
'subcategory_id',
];
protected $casts = [ protected $casts = [
'embedding' => VectorCast::class, 'embedding' => VectorCast::class,
@@ -18,4 +28,14 @@ class Article extends Model
{ {
return $this->hasMany(AIDecision::class); return $this->hasMany(AIDecision::class);
} }
public function category(): BelongsTo
{
return $this->belongsTo(Category::class, 'category_id');
}
public function subcategory(): BelongsTo
{
return $this->belongsTo(Category::class, 'subcategory_id');
}
} }

22
app/Models/Category.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
protected $fillable = ['external_id', 'parent_id', 'name', 'slug'];
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Services; namespace App\Services;
use App\Models\Article; use App\Models\Article;
use App\Models\Category;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -10,54 +11,73 @@ class HelpdeskImportService
{ {
private const DEFAULT_BASE_URL = 'https://www.internettoday.nl/helpdesk'; private const DEFAULT_BASE_URL = 'https://www.internettoday.nl/helpdesk';
public function import(?string $baseUrl = null, bool $dryRun = false, ?int $limit = null): array public function import(?string $baseUrl = null, bool $dryRun = false, ?int $limit = null, ?callable $progress = null): array
{ {
$baseUrl = rtrim($baseUrl ?: self::DEFAULT_BASE_URL, '/'); $baseUrl = rtrim($baseUrl ?: self::DEFAULT_BASE_URL, '/');
$rootHtml = $this->fetch($baseUrl); $rootHtml = $this->fetch($baseUrl);
$categories = $this->extractCategories($rootHtml); $categories = $this->extractCategories($rootHtml);
$sectionUrls = $this->buildSectionUrls($baseUrl, $categories); $categoryMap = $this->syncCategories($categories, $dryRun);
$articleUrls = $this->collectArticleUrls($baseUrl, $rootHtml, $sectionUrls); $sections = $this->buildSections($baseUrl, $categories);
$articleUrlMap = $this->collectArticleUrls($baseUrl, $rootHtml, $sections);
if ($limit !== null && $limit > 0) { if ($limit !== null && $limit > 0) {
$articleUrls = array_slice($articleUrls, 0, $limit); $articleUrlMap = array_slice($articleUrlMap, 0, $limit, true);
} }
$total = count($articleUrlMap);
$imported = 0; $imported = 0;
$updated = 0; $updated = 0;
$skipped = 0; $skipped = 0;
$processed = 0;
foreach ($articleUrls as $articleUrl) { foreach ($articleUrlMap as $articleUrl => $meta) {
$processed++;
$parsed = $this->parseArticlePage($articleUrl); $parsed = $this->parseArticlePage($articleUrl);
if ($parsed === null) { if ($parsed === null) {
$skipped++; $skipped++;
$progress && $progress($processed, $total, $articleUrl, 'skipped');
continue; continue;
} }
if ($dryRun) { if ($dryRun) {
$imported++; $imported++;
$progress && $progress($processed, $total, $articleUrl, 'dry-run');
continue; continue;
} }
[$title, $content] = $parsed; [$title, $content, $sourceArticleId] = $parsed;
$result = Article::withoutEvents(function () use ($title, $content) {
$categoryId = $this->resolveCategoryId($meta['category_external_id'] ?? null, $categoryMap);
$subcategoryId = $this->resolveCategoryId($meta['subcategory_external_id'] ?? null, $categoryMap);
$result = Article::withoutEvents(function () use ($title, $content, $articleUrl, $sourceArticleId, $categoryId, $subcategoryId) {
return Article::query()->updateOrCreate( return Article::query()->updateOrCreate(
['title' => $title], ['source' => 'internettoday_helpdesk', 'source_article_id' => $sourceArticleId],
['content' => $content] [
'title' => $title,
'content' => $content,
'source_url' => $articleUrl,
'category_id' => $categoryId,
'subcategory_id' => $subcategoryId,
]
); );
}); });
if ($result->wasRecentlyCreated) { if ($result->wasRecentlyCreated) {
$imported++; $imported++;
$progress && $progress($processed, $total, $articleUrl, 'imported');
} else { } else {
$updated++; $updated++;
$progress && $progress($processed, $total, $articleUrl, 'updated');
} }
} }
return [ return [
'categories' => count($categories), 'categories' => count($categories),
'sections' => count($sectionUrls), 'sections' => count($sections),
'article_urls' => count($articleUrls), 'article_urls' => $total,
'imported' => $imported, 'imported' => $imported,
'updated' => $updated, 'updated' => $updated,
'skipped' => $skipped, 'skipped' => $skipped,
@@ -67,11 +87,7 @@ class HelpdeskImportService
private function fetch(string $url): string private function fetch(string $url): string
{ {
return Http::timeout(30) return Http::timeout(30)->retry(2, 300)->get($url)->throw()->body();
->retry(2, 300)
->get($url)
->throw()
->body();
} }
private function extractCategories(string $html): array private function extractCategories(string $html): array
@@ -84,46 +100,102 @@ class HelpdeskImportService
return is_array($decoded) ? $decoded : []; return is_array($decoded) ? $decoded : [];
} }
private function buildSectionUrls(string $baseUrl, array $categories): array private function syncCategories(array $categories, bool $dryRun): array
{ {
$urls = []; $map = [];
foreach ($categories as $category) {
if (!isset($category['id'], $category['title'], $category['slug'])) {
continue;
}
$parentId = null;
if (!$dryRun) {
$model = Category::query()->updateOrCreate(
['external_id' => (int) $category['id']],
['name' => (string) $category['title'], 'slug' => (string) $category['slug'], 'parent_id' => null]
);
$parentId = $model->id;
}
$map[(int) $category['id']] = $parentId;
foreach (($category['children'] ?? []) as $child) {
if (!isset($child['id'], $child['title'], $child['slug'])) {
continue;
}
if (!$dryRun && $parentId !== null) {
$childModel = Category::query()->updateOrCreate(
['external_id' => (int) $child['id']],
['name' => (string) $child['title'], 'slug' => (string) $child['slug'], 'parent_id' => $parentId]
);
$map[(int) $child['id']] = $childModel->id;
} else {
$map[(int) $child['id']] = null;
}
}
}
return $map;
}
private function buildSections(string $baseUrl, array $categories): array
{
$sections = [];
foreach ($categories as $category) { foreach ($categories as $category) {
if (!isset($category['id'], $category['slug'])) { if (!isset($category['id'], $category['slug'])) {
continue; continue;
} }
$urls[] = sprintf('%s/%d/%s', $baseUrl, (int) $category['id'], (string) $category['slug']); $sections[] = [
'url' => sprintf('%s/%d/%s', $baseUrl, (int) $category['id'], (string) $category['slug']),
'category_external_id' => (int) $category['id'],
'subcategory_external_id' => null,
];
foreach (($category['children'] ?? []) as $child) { foreach (($category['children'] ?? []) as $child) {
if (!isset($child['id'], $child['slug'])) { if (!isset($child['id'], $child['slug'])) {
continue; continue;
} }
$urls[] = sprintf('%s/%d/%s', $baseUrl, (int) $child['id'], (string) $child['slug']); $sections[] = [
'url' => sprintf('%s/%d/%s', $baseUrl, (int) $child['id'], (string) $child['slug']),
'category_external_id' => (int) $category['id'],
'subcategory_external_id' => (int) $child['id'],
];
} }
} }
return array_values(array_unique($urls)); return $sections;
} }
private function collectArticleUrls(string $baseUrl, string $rootHtml, array $sectionUrls): array private function collectArticleUrls(string $baseUrl, string $rootHtml, array $sections): array
{ {
$urls = []; $result = [];
$sources = array_merge([
['url' => $baseUrl, 'category_external_id' => null, 'subcategory_external_id' => null, 'html' => $rootHtml],
], $sections);
foreach (array_merge([$baseUrl], $sectionUrls) as $url) { foreach ($sources as $source) {
try { try {
$html = $url === $baseUrl ? $rootHtml : $this->fetch($url); $html = $source['html'] ?? $this->fetch($source['url']);
} catch (\Throwable) { } catch (\Throwable) {
continue; continue;
} }
preg_match_all('/https:\/\/www\.internettoday\.nl\/helpdesk\/(\d+)-[a-z0-9\-]+/i', $html, $matches); preg_match_all('/https:\/\/www\.internettoday\.nl\/helpdesk\/(\d+)-[a-z0-9\-]+/i', $html, $matches);
foreach (($matches[0] ?? []) as $match) { foreach (($matches[0] ?? []) as $match) {
$urls[] = strtolower($match); $url = strtolower($match);
if (!isset($result[$url])) {
$result[$url] = [
'category_external_id' => $source['category_external_id'],
'subcategory_external_id' => $source['subcategory_external_id'],
];
}
} }
} }
return array_values(array_unique($urls)); return $result;
} }
private function parseArticlePage(string $url): ?array private function parseArticlePage(string $url): ?array
@@ -156,9 +228,20 @@ class HelpdeskImportService
return null; return null;
} }
$content = "Source: {$url}\n\n{$content}"; if (!preg_match('/\/helpdesk\/(\d+)-/', $url, $idMatch)) {
return null;
}
return [$title, Str::limit($content, 64000, '')]; return [$title, Str::limit($content, 64000, ''), (int) $idMatch[1]];
}
private function resolveCategoryId(?int $externalId, array $map): ?int
{
if ($externalId === null) {
return null;
}
return $map[$externalId] ?? Category::query()->where('external_id', $externalId)->value('id');
} }
private function sanitizeText(string $value): string private function sanitizeText(string $value): string

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('external_id')->nullable()->unique();
$table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete();
$table->string('name');
$table->string('slug')->nullable();
$table->timestamps();
$table->index(['parent_id', 'name']);
});
Schema::table('articles', function (Blueprint $table) {
$table->string('source')->nullable()->after('content');
$table->string('source_url')->nullable()->after('source');
$table->unsignedBigInteger('source_article_id')->nullable()->after('source_url');
$table->foreignId('category_id')->nullable()->after('source_article_id')->constrained('categories')->nullOnDelete();
$table->foreignId('subcategory_id')->nullable()->after('category_id')->constrained('categories')->nullOnDelete();
$table->index('source_article_id');
});
}
public function down(): void
{
Schema::table('articles', function (Blueprint $table) {
$table->dropConstrainedForeignId('subcategory_id');
$table->dropConstrainedForeignId('category_id');
$table->dropColumn(['source', 'source_url', 'source_article_id']);
});
Schema::dropIfExists('categories');
}
};

View File

@@ -1,5 +1,16 @@
<div class="bg-white rounded-xl p-4 shadow"> <div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Tickets + AI Decisions</h2> <div class="flex items-center justify-between mb-3">
<h2 class="font-semibold">Tickets + AI Decisions</h2>
<div class="flex items-center gap-2 text-sm">
<label for="perPage">Per pagina</label>
<select id="perPage" wire:model.live="perPage" class="border rounded px-2 py-1">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
</div>
</div>
<div class="space-y-3"> <div class="space-y-3">
@foreach($tickets as $ticket) @foreach($tickets as $ticket)
<div class="border rounded p-3"> <div class="border rounded p-3">