feat: Phase 4 — topic CRUD UI in 'ניהול' tab
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
<select> while keeping data intact. Eye icon
flips between "השבת" and "הפעל"
🗑 Delete — hard-delete; only allowed when source_count=0.
Otherwise the UI directs the user to soft-disable
instead
Any topic mutation (create / rename / toggle / delete) invalidates
the cached _topics list so the user-facing dropdown re-fetches
immediately — admins don't have to refresh the page to see their
own changes.
Service.request() now distinguishes 4xx from 5xx upstream errors:
4xx is rethrown as BadRequest (so the user sees "slug already exists"
instead of a generic 500), 5xx stays as Error.
Backend: depends on shira-hermes commit 49503ca (admin topic
endpoints). No new migration — kb_topic schema from migration 001
is sufficient.
Description field in manifest was rewritten to Hebrew to match the
in-CRM scopeNames label ("מאגר ידע").
Refs Task Master #15
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,23 @@
|
||||
|
||||
{{#ifEqual mode 'manage'}}
|
||||
<div class="kb-manage" style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div class="panel panel-default kb-topics-panel" style="padding:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
||||
<h4 style="margin:0;">נושאים (תחומי משפט)</h4>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="refreshTopics" title="רענן">
|
||||
<span class="glyphicon glyphicon-refresh"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-action="newTopic">
|
||||
<span class="glyphicon glyphicon-plus"></span> נושא חדש
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kb-topics-table">
|
||||
<div class="text-muted">טוען נושאים…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default kb-sources-panel" style="padding:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
||||
<h4 style="margin:0;">מקורות בנושא<span class="kb-topic-name-suffix"></span></h4>
|
||||
|
||||
@@ -16,6 +16,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
let _activeJobTopicId = null; // topic this job belongs to (for cross-topic guard)
|
||||
let _pollIntervalId = null; // setInterval handle so remounts don't double-poll
|
||||
let _adminSourcesByTopic = {}; // {topicId: [...sources]} cache for the manage tab
|
||||
let _adminTopics = null; // [...topics admin view] cache (incl. is_active=false)
|
||||
|
||||
const SS_ASK = 'kb-last-ask';
|
||||
const SS_SEARCH = 'kb-last-search';
|
||||
@@ -156,6 +157,39 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._reingestSource(id);
|
||||
},
|
||||
'click [data-action="refreshTopics"]': function (e) {
|
||||
e.preventDefault();
|
||||
this._loadAdminTopics(/* force */ true);
|
||||
},
|
||||
'click [data-action="newTopic"]': function (e) {
|
||||
e.preventDefault();
|
||||
this._openTopicForm(null);
|
||||
},
|
||||
'click [data-action="editTopic"]': function (e) {
|
||||
e.preventDefault();
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._openTopicForm(id);
|
||||
},
|
||||
'click [data-action="cancelTopicEdit"]': function (e) {
|
||||
e.preventDefault();
|
||||
this._renderTopicsTable();
|
||||
},
|
||||
'click [data-action="saveTopic"]': function (e) {
|
||||
e.preventDefault();
|
||||
const idAttr = $(e.currentTarget).data('id');
|
||||
const id = (idAttr === '' || idAttr == null) ? null : parseInt(idAttr, 10);
|
||||
this._saveTopic(id);
|
||||
},
|
||||
'click [data-action="toggleTopicActive"]': function (e) {
|
||||
e.preventDefault();
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._toggleTopicActive(id);
|
||||
},
|
||||
'click [data-action="deleteTopic"]': function (e) {
|
||||
e.preventDefault();
|
||||
const id = parseInt($(e.currentTarget).data('id'), 10);
|
||||
if (id) this._deleteTopic(id);
|
||||
},
|
||||
},
|
||||
|
||||
// Cancel any in-flight ask/search, drop the cached last-result for
|
||||
@@ -249,6 +283,8 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
this._loadJobsList();
|
||||
// Phase 3: load the sources table when the manage tab is open.
|
||||
this._loadAdminSources();
|
||||
// Phase 4: load the topics admin table (firm-wide, not topic-scoped).
|
||||
this._loadAdminTopics();
|
||||
},
|
||||
|
||||
_loadJobsList: function () {
|
||||
@@ -307,6 +343,241 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
|
||||
$list.html(rows);
|
||||
},
|
||||
|
||||
// ── Phase 4: topics admin (Task #15) ───────────────────────────────
|
||||
|
||||
_loadAdminTopics: function (force) {
|
||||
const self = this;
|
||||
const $table = this.$el.find('.kb-topics-table');
|
||||
if (!$table.length) return;
|
||||
if (!force && _adminTopics) {
|
||||
this._renderTopicsTable();
|
||||
return;
|
||||
}
|
||||
$table.html('<div class="text-muted">טוען נושאים…</div>');
|
||||
Espo.Ajax.getRequest('KnowledgeBase/action/adminTopics')
|
||||
.then(res => {
|
||||
_adminTopics = (res && res.items) || [];
|
||||
self._renderTopicsTable();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('KB: failed to load admin topics', err);
|
||||
$table.html('<div class="text-muted">שגיאה בטעינת נושאים.</div>');
|
||||
});
|
||||
},
|
||||
|
||||
_renderTopicsTable: function () {
|
||||
const $table = this.$el.find('.kb-topics-table');
|
||||
if (!$table.length) return;
|
||||
const items = _adminTopics || [];
|
||||
if (!items.length) {
|
||||
$table.html('<div class="text-muted">אין נושאים. לחץ "נושא חדש" כדי להוסיף את הראשון.</div>');
|
||||
return;
|
||||
}
|
||||
const rows = items.map(t => {
|
||||
const activeBadge = t.is_active
|
||||
? '<span class="label label-success">פעיל</span>'
|
||||
: '<span class="label label-default">מושבת</span>';
|
||||
const sources = (t.source_count != null) ? t.source_count : 0;
|
||||
const desc = t.description ? `<div class="text-muted small">${this.escape(t.description)}</div>` : '';
|
||||
return `<tr data-id="${t.id}">
|
||||
<td><code style="direction:ltr;">${this.escape(t.slug)}</code></td>
|
||||
<td>
|
||||
<div style="font-weight:500;">${this.escape(t.name)}</div>
|
||||
${desc}
|
||||
</td>
|
||||
<td class="text-center">${sources}</td>
|
||||
<td class="text-center">${activeBadge}</td>
|
||||
<td style="white-space:nowrap;text-align:left;">
|
||||
<button class="btn btn-link btn-sm" data-action="editTopic" data-id="${t.id}" title="ערוך"><span class="glyphicon glyphicon-pencil"></span></button>
|
||||
<button class="btn btn-link btn-sm" data-action="toggleTopicActive" data-id="${t.id}" title="${t.is_active ? 'השבת' : 'הפעל'}"><span class="glyphicon glyphicon-${t.is_active ? 'eye-close' : 'eye-open'}"></span></button>
|
||||
<button class="btn btn-link btn-sm" data-action="deleteTopic" data-id="${t.id}" title="מחק" style="color:#d9534f;"><span class="glyphicon glyphicon-trash"></span></button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
$table.html(`
|
||||
<table class="table table-condensed table-hover" style="margin:0;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:160px;">slug</th>
|
||||
<th>שם / תיאור</th>
|
||||
<th style="width:60px;text-align:center;">מקורות</th>
|
||||
<th style="width:80px;text-align:center;">סטטוס</th>
|
||||
<th style="width:120px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`);
|
||||
},
|
||||
|
||||
_findTopicById: function (id) {
|
||||
const arr = _adminTopics || [];
|
||||
return arr.find(t => t.id === id);
|
||||
},
|
||||
|
||||
_openTopicForm: function (id) {
|
||||
const isNew = id == null;
|
||||
const t = isNew ? null : this._findTopicById(id);
|
||||
if (!isNew && !t) return;
|
||||
const $tbody = this.$el.find('.kb-topics-table tbody');
|
||||
if (!$tbody.length) return;
|
||||
const safe = (v) => this.escape(v == null ? '' : String(v));
|
||||
// For new topics, prepend a fresh editable row. For edits, replace
|
||||
// the existing row in place. Either way the form lives inside one
|
||||
// <td colspan="5"> for layout simplicity.
|
||||
const formHtml = `
|
||||
<tr data-id="${isNew ? '' : id}" class="kb-topic-edit-row">
|
||||
<td colspan="5">
|
||||
<div style="display:flex;flex-direction:column;gap:8px;padding:8px;background:#fafafa;border-radius:4px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">slug:</label>
|
||||
<input type="text" class="form-control" data-edit="slug" value="${safe(t && t.slug)}" ${isNew ? '' : 'disabled'} placeholder="employment-law" style="flex:0 0 240px;direction:ltr;" />
|
||||
<span class="text-muted small">${isNew ? 'אותיות קטנות, מקפים. לא ניתן לשנות לאחר היצירה.' : 'אינו ניתן לשינוי.'}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">שם:</label>
|
||||
<input type="text" class="form-control" data-edit="name" value="${safe(t && t.name)}" placeholder="דיני עבודה" style="flex:1 1 240px;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;padding-top:6px;">תיאור:</label>
|
||||
<input type="text" class="form-control" data-edit="description" value="${safe(t && t.description)}" placeholder="תיאור קצר של הנושא (אופציונלי)" style="flex:1 1 240px;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;padding-top:6px;">תוספת לפרומפט:</label>
|
||||
<textarea class="form-control" data-edit="system_prompt_addendum" rows="4" placeholder="הקשר נוסף שיינתן ל-LLM. למשל: 'You are answering legal questions about employment law in Israel. Cite sections explicitly when possible.'" style="flex:1 1 240px;direction:ltr;font-family:monospace;font-size:0.9em;">${safe(t && t.system_prompt_addendum)}</textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">פעיל:</label>
|
||||
<input type="checkbox" data-edit="is_active" ${(!t || t.is_active) ? 'checked' : ''} />
|
||||
<span class="text-muted small">מוסתר ממשתמשים אם לא פעיל.</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:4px;">
|
||||
<button class="btn btn-default btn-sm" data-action="cancelTopicEdit">ביטול</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="saveTopic" data-id="${isNew ? '' : id}">${isNew ? 'צור' : 'שמור'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user