From db7b30e3c05f7689b13e9210f5bf12dc51144a89 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 25 Apr 2026 17:46:51 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20topic=20CRUD=20?= =?UTF-8?q?UI=20in=20'=D7=A0=D7=99=D7=94=D7=95=D7=9C'=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a topics-management panel above the sources table. Admins can create new legal domains (דיני עבודה, דין פלילי, נדל"ן) without running SQL — slug + name + description + system prompt addendum all live in one inline form. Per-row actions: ✏ Edit — opens inline form with all fields except slug (slug is locked post-create — the S3 layout depends on it) 👁 Toggle active — soft-disable: hides the topic from the user-facing + ${isNew ? 'אותיות קטנות, מקפים. לא ניתן לשנות לאחר היצירה.' : 'אינו ניתן לשינוי.'} + +
+ + +
+
+ + +
+
+ + +
+
+ + + מוסתר ממשתמשים אם לא פעיל. +
+
+ + +
+ + + + `; + if (isNew) { + $tbody.prepend(formHtml); + this.$el.find('.kb-topic-edit-row input[data-edit="slug"]').trigger('focus'); + } else { + const $row = this.$el.find(`.kb-topics-table tr[data-id="${id}"]`); + $row.replaceWith(formHtml); + this.$el.find('.kb-topic-edit-row input[data-edit="name"]').trigger('focus'); + } + }, + + _saveTopic: function (id) { + const self = this; + const $row = this.$el.find('.kb-topic-edit-row'); + if (!$row.length) return; + const payload = {}; + $row.find('[data-edit]').each(function () { + const k = $(this).data('edit'); + if ($(this).is(':checkbox')) { + payload[k] = $(this).is(':checked'); + } else if (!$(this).is('[disabled]')) { + payload[k] = ($(this).val() || '').trim(); + } + }); + const isNew = id == null; + const url = isNew + ? 'KnowledgeBase/action/createTopic' + : 'KnowledgeBase/action/updateTopic'; + if (!isNew) { + payload.id = id; + // PUT semantics: don't send slug (it's locked) — Service whitelists anyway. + delete payload.slug; + } + const $btn = $row.find('[data-action="saveTopic"]'); + const oldText = $btn.text(); + $btn.prop('disabled', true).text('שומר…'); + + Espo.Ajax.postRequest(url, payload) + .then(saved => { + if (isNew) { + _adminTopics = (_adminTopics || []).concat([ + Object.assign({source_count: 0}, saved), + ]); + } else { + const idx = (_adminTopics || []).findIndex(t => t.id === id); + if (idx >= 0) { + _adminTopics[idx] = Object.assign({}, _adminTopics[idx], saved); + } + } + self._renderTopicsTable(); + // The user-facing topic dropdown might now be stale (rename + // / new active topic) — invalidate it so afterRender re-fetches. + _topics = null; + _topicsPromise = null; + self._ensureTopicsLoaded().then(() => self._populateTopicPicker()); + }) + .catch(err => { + $btn.prop('disabled', false).text(oldText); + let msg = ''; + try { + const j = JSON.parse(err && err.responseText || '{}'); + msg = j.message || j.detail || (err && err.responseText) || 'שגיאה לא ידועה'; + } catch (e) { + msg = (err && err.responseText) || 'שגיאה לא ידועה'; + } + alert('שמירה נכשלה: ' + String(msg).slice(0, 300)); + }); + }, + + _toggleTopicActive: function (id) { + const t = this._findTopicById(id); + if (!t) return; + const next = !t.is_active; + const verb = next ? 'להפעיל' : 'להשבית'; + if (!confirm(`${verb} את הנושא "${t.name}"?`)) return; + const self = this; + Espo.Ajax.postRequest('KnowledgeBase/action/updateTopic', {id, is_active: next}) + .then(updated => { + const idx = (_adminTopics || []).findIndex(x => x.id === id); + if (idx >= 0) { + _adminTopics[idx] = Object.assign({}, _adminTopics[idx], updated); + } + self._renderTopicsTable(); + _topics = null; + _topicsPromise = null; + self._ensureTopicsLoaded().then(() => self._populateTopicPicker()); + }) + .catch(err => alert('פעולה נכשלה: ' + ((err && err.responseText) || '').slice(0, 300))); + }, + + _deleteTopic: function (id) { + const t = this._findTopicById(id); + if (!t) return; + const cn = t.source_count || 0; + if (cn > 0) { + alert(`לא ניתן למחוק את "${t.name}" כי יש לו ${cn} מקורות.\n\nניתן להשבית את הנושא במקום זאת — הוא ייעלם מהדרופ-דאון אך הנתונים יישמרו.`); + return; + } + if (!confirm(`למחוק לחלוטין את הנושא "${t.name}" (slug=${t.slug})?\n\nאין לו מקורות, אבל הפעולה בלתי הפיכה.`)) return; + const self = this; + Espo.Ajax.postRequest('KnowledgeBase/action/deleteTopic', {id}) + .then(() => { + _adminTopics = (_adminTopics || []).filter(x => x.id !== id); + self._renderTopicsTable(); + _topics = null; + _topicsPromise = null; + self._ensureTopicsLoaded().then(() => self._populateTopicPicker()); + }) + .catch(err => { + let msg = ''; + try { + const j = JSON.parse(err && err.responseText || '{}'); + msg = j.message || j.detail || 'שגיאה לא ידועה'; + } catch (e) { + msg = (err && err.responseText) || 'שגיאה לא ידועה'; + } + alert('מחיקה נכשלה: ' + String(msg).slice(0, 300)); + }); + }, + // ── Phase 3: sources table (Task #14) ────────────────────────────── _loadAdminSources: function (force) { diff --git a/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php b/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php index 78174b4..f1356d2 100644 --- a/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php +++ b/files/custom/Espo/Modules/KnowledgeBase/Controllers/KnowledgeBase.php @@ -252,4 +252,59 @@ class KnowledgeBase (string) $this->user->get('userName') ); } + + // ── Phase 4: topic CRUD (Task #15) ────────────────────────────────────── + + public function getActionAdminTopics(Request $request, Response $response): array + { + $this->checkAccess(); + return $this->getService()->listAdminTopics(); + } + + public function postActionCreateTopic(Request $request, Response $response): array + { + $this->checkAccess(); + $body = $request->getParsedBody(); + if (empty($body->slug) || empty($body->name)) { + throw new BadRequest('slug and name are required.'); + } + return $this->getService()->createAdminTopic([ + 'slug' => trim((string) $body->slug), + 'name' => trim((string) $body->name), + 'description' => isset($body->description) ? trim((string) $body->description) : null, + 'system_prompt_addendum' => $body->system_prompt_addendum ?? $body->systemPromptAddendum ?? null, + 'is_active' => isset($body->is_active) ? (bool) $body->is_active : true, + ]); + } + + public function postActionUpdateTopic(Request $request, Response $response): array + { + $this->checkAccess(); + $body = $request->getParsedBody(); + $id = $body->id ?? null; + if (!$id || !is_numeric($id)) { + throw new BadRequest('id (numeric) is required.'); + } + $payload = []; + foreach (['name', 'description', 'system_prompt_addendum', 'systemPromptAddendum', 'is_active', 'isActive'] as $k) { + if (property_exists($body, $k)) { + $canon = $k; + if ($k === 'systemPromptAddendum') $canon = 'system_prompt_addendum'; + if ($k === 'isActive') $canon = 'is_active'; + $payload[$canon] = $body->$k; + } + } + return $this->getService()->updateAdminTopic((int) $id, $payload); + } + + public function postActionDeleteTopic(Request $request, Response $response): array + { + $this->checkAccess(); + $body = $request->getParsedBody(); + $id = $body->id ?? null; + if (!$id || !is_numeric($id)) { + throw new BadRequest('id (numeric) is required.'); + } + return $this->getService()->deleteAdminTopic((int) $id); + } } diff --git a/files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json b/files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json index e5ff6db..4a10c66 100644 --- a/files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json +++ b/files/custom/Espo/Modules/KnowledgeBase/Resources/routes.json @@ -94,5 +94,37 @@ "controller": "KnowledgeBase", "action": "reingestSource" } + }, + { + "route": "/KnowledgeBase/action/adminTopics", + "method": "get", + "params": { + "controller": "KnowledgeBase", + "action": "adminTopics" + } + }, + { + "route": "/KnowledgeBase/action/createTopic", + "method": "post", + "params": { + "controller": "KnowledgeBase", + "action": "createTopic" + } + }, + { + "route": "/KnowledgeBase/action/updateTopic", + "method": "post", + "params": { + "controller": "KnowledgeBase", + "action": "updateTopic" + } + }, + { + "route": "/KnowledgeBase/action/deleteTopic", + "method": "post", + "params": { + "controller": "KnowledgeBase", + "action": "deleteTopic" + } } ] diff --git a/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php b/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php index 7d567e0..93eb2af 100644 --- a/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php +++ b/files/custom/Espo/Modules/KnowledgeBase/Services/KnowledgeBaseService.php @@ -205,6 +205,28 @@ class KnowledgeBaseService ); } + // ── Phase 4: topic CRUD (Task #15) ────────────────────────────────────── + + public function listAdminTopics(): array + { + return $this->get('/admin/kb/topics'); + } + + public function createAdminTopic(array $payload): array + { + return $this->request('POST', '/admin/kb/topics', $payload); + } + + public function updateAdminTopic(int $topicId, array $fields): array + { + return $this->request('PUT', '/admin/kb/topics/' . $topicId, $fields); + } + + public function deleteAdminTopic(int $topicId): array + { + return $this->request('DELETE', '/admin/kb/topics/' . $topicId, null); + } + private function postWithUser(string $path, ?array $payload, string $username): array { $url = $this->getBaseUrl() . $path; @@ -491,6 +513,13 @@ class KnowledgeBaseService } if ($httpCode < 200 || $httpCode >= 300) { $this->log->error("KnowledgeBase: HTTP {$httpCode} from {$url}: {$body}"); + // Surface 4xx error detail to the browser as BadRequest so the + // user sees the actual reason (bad slug, duplicate, conflict) + // rather than a generic 500. + if ($httpCode >= 400 && $httpCode < 500) { + $detail = $this->decodeDetail($body) ?: "HTTP {$httpCode}"; + throw new BadRequest($detail); + } throw new Error("Knowledge Base returned HTTP {$httpCode}"); } $decoded = json_decode($body, true); diff --git a/manifest.json b/manifest.json index 16c247b..4f6592a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "KnowledgeBase", "module": "KnowledgeBase", - "version": "0.6.0", + "version": "0.7.0", "acceptableVersions": [ ">=8.0.0" ], @@ -10,5 +10,5 @@ ], "releaseDate": "2026-04-25", "author": "klear", - "description": "Knowledge Base — Israeli National Insurance law, regulations, and circulars. Hybrid search + ask-shira, powered by shira-hermes KB." + "description": "מאגר ידע — חוק הביטוח הלאומי, תקנות וחוזרים" } \ No newline at end of file