Skip to main content

zammad_api/api/
knowledge_base.rs

1//! Knowledge Base ("FAQ") endpoints.
2//!
3//! Zammad's Knowledge Base API does not hand back plain records the way the
4//! ticket or user endpoints do. Instead it returns an *assets* bundle: a set of
5//! id-keyed maps (`KnowledgeBaseAnswer`, `KnowledgeBaseAnswerTranslation`,
6//! `KnowledgeBaseAnswerTranslationContent`, `KnowledgeBaseCategory`, ...) that
7//! the caller is expected to stitch together. This module hides that stitching
8//! and exposes flat [`KbAnswer`] / [`KbSearchHit`] values.
9//!
10//! Three operations are provided:
11//!
12//! - [`KnowledgeBaseApi::list`] — every answer with its title and category
13//!   (no body).
14//! - [`KnowledgeBaseApi::search`] — full-text search, returning highlighted
15//!   title/body snippets.
16//! - [`KnowledgeBaseApi::get`] — a single answer including its full body.
17//!
18//! The answer body is not part of the default `init` payload; it is fetched in
19//! a second `init` call that names the wanted
20//! `answer_translation_content_ids`.
21
22use std::collections::BTreeMap;
23
24use reqwest::Method;
25use serde::Deserialize;
26
27use crate::client::Client;
28use crate::error::{Error, Result};
29
30const INIT_PATH: &str = "api/v1/knowledge_bases/init";
31const SEARCH_PATH: &str = "api/v1/knowledge_bases/search";
32
33/// Publishing state-machine action that makes an answer public.
34const PUBLISH_ACTION: &str = "publish";
35/// Publishing state-machine action that moves an answer back to a draft.
36const UNPUBLISH_ACTION: &str = "unpublish";
37
38/// Knowledge Base (FAQ) endpoints.
39#[derive(Debug)]
40pub struct KnowledgeBaseApi<'a> {
41    client: &'a Client,
42}
43
44impl<'a> KnowledgeBaseApi<'a> {
45    pub(crate) fn new(client: &'a Client) -> Self {
46        Self { client }
47    }
48
49    /// List every answer known to the instance, flattened to
50    /// [`KbAnswer`]s (title + category, no body).
51    ///
52    /// `POST /api/v1/knowledge_bases/init`.
53    pub async fn list(&self) -> Result<Vec<KbAnswer>> {
54        let assets = self.init(&[]).await?;
55        let mut answers: Vec<KbAnswer> = assets
56            .answers
57            .iter()
58            .map(|(&id, raw)| assets.to_answer(id, raw, None))
59            .collect();
60        answers.sort_by_key(|a| a.id);
61        Ok(answers)
62    }
63
64    /// Fetch a single answer by its numeric id, including its full body.
65    ///
66    /// Returns [`Error::NotFound`] when no answer with that id exists.
67    ///
68    /// Issues two `POST /api/v1/knowledge_bases/init` calls: one for the
69    /// structure and one that requests the answer's translation content.
70    pub async fn get(&self, answer_id: u64) -> Result<KbAnswer> {
71        let assets = self.init(&[]).await?;
72        let raw = assets.answers.get(&answer_id).ok_or(Error::NotFound)?;
73
74        // The body lives on the translation's content record, addressed by
75        // `content_id`. Ask a second `init` to include it.
76        let content_id = assets.primary_translation(raw).and_then(|t| t.content_id);
77        let body = self.body_of(content_id).await?;
78
79        Ok(assets.to_answer(answer_id, raw, Some(body)))
80    }
81
82    /// Create a new answer in a category, in the knowledge base's primary
83    /// locale.
84    ///
85    /// When [`NewKbAnswer::published`] is `true` the answer is published in the
86    /// same request; otherwise it is left as a draft. The knowledge base and
87    /// locale are resolved from the category.
88    ///
89    /// `POST /api/v1/knowledge_bases/:kb_id/answers`.
90    pub async fn create_answer(&self, answer: NewKbAnswer) -> Result<KbAnswer> {
91        let assets = self.init(&[]).await?;
92        let kb_id = assets.kb_of_category(answer.category_id).ok_or_else(|| {
93            Error::Config(format!(
94                "no knowledge base found for category {}",
95                answer.category_id
96            ))
97        })?;
98        let kb_locale_id = assets
99            .primary_locale_id(kb_id)
100            .ok_or_else(|| Error::Config(format!("no locale found for knowledge base {kb_id}")))?;
101
102        let mut body = serde_json::json!({
103            "category_id": answer.category_id,
104            "translations": [{
105                "title": answer.title,
106                "kb_locale_id": kb_locale_id,
107                "content": { "body": answer.body },
108            }],
109        });
110        if answer.published {
111            body["additional_action"] = serde_json::json!(PUBLISH_ACTION);
112        }
113
114        let path = format!("api/v1/knowledge_bases/{kb_id}/answers");
115        let req = self.client.request(Method::POST, &path).json(&body);
116        let created: AnswerWriteResponse = self.client.send_json(req).await?;
117        self.get(created.id).await
118    }
119
120    /// Update an existing answer's title, body, and/or published state. Fields
121    /// left as `None` in the [`KbAnswerPatch`] are preserved.
122    ///
123    /// Returns [`Error::NotFound`] when no answer with that id exists.
124    ///
125    /// `PATCH /api/v1/knowledge_bases/:kb_id/answers/:id`.
126    pub async fn update_answer(&self, answer_id: u64, patch: KbAnswerPatch) -> Result<KbAnswer> {
127        let assets = self.init(&[]).await?;
128        let raw = assets.answers.get(&answer_id).ok_or(Error::NotFound)?;
129        let category_id = raw
130            .category_id
131            .ok_or_else(|| Error::Config("answer has no category".into()))?;
132        let kb_id = assets.kb_of_category(category_id).ok_or_else(|| {
133            Error::Config(format!(
134                "no knowledge base found for category {category_id}"
135            ))
136        })?;
137        let kb_locale_id = assets
138            .primary_locale_id(kb_id)
139            .ok_or_else(|| Error::Config(format!("no locale found for knowledge base {kb_id}")))?;
140
141        let translation_id = *raw
142            .translation_ids
143            .first()
144            .ok_or_else(|| Error::Config("answer has no translation to update".into()))?;
145        let translation = assets
146            .translations
147            .get(&translation_id)
148            .ok_or_else(|| Error::Config("answer translation missing from payload".into()))?;
149        let content_id = translation.content_id;
150
151        // Merge: keep the current title/body for whichever field the caller
152        // left unset, so a partial update never blanks the other.
153        let title = match patch.title {
154            Some(t) => t,
155            None => translation.title.clone().unwrap_or_default(),
156        };
157        let body = match patch.body {
158            Some(b) => b,
159            None => self.body_of(content_id).await?.unwrap_or_default(),
160        };
161
162        let mut content = serde_json::json!({ "body": body });
163        if let Some(cid) = content_id {
164            content["id"] = serde_json::json!(cid);
165        }
166        let mut payload = serde_json::json!({
167            "category_id": category_id,
168            "translations": [{
169                "id": translation_id,
170                "kb_locale_id": kb_locale_id,
171                "title": title,
172                "content": content,
173            }],
174        });
175        if let Some(published) = patch.published {
176            let action = if published {
177                PUBLISH_ACTION
178            } else {
179                UNPUBLISH_ACTION
180            };
181            payload["additional_action"] = serde_json::json!(action);
182        }
183
184        let path = format!("api/v1/knowledge_bases/{kb_id}/answers/{answer_id}");
185        let req = self.client.request(Method::PATCH, &path).json(&payload);
186        let _: AnswerWriteResponse = self.client.send_json(req).await?;
187        self.get(answer_id).await
188    }
189
190    /// Fetch the body for a translation content id, if any.
191    async fn body_of(&self, content_id: Option<u64>) -> Result<Option<String>> {
192        let Some(cid) = content_id else {
193            return Ok(None);
194        };
195        let assets = self.init(&[cid]).await?;
196        Ok(assets.contents.get(&cid).and_then(|c| c.body.clone()))
197    }
198
199    /// Full-text search across the Knowledge Base.
200    ///
201    /// Returns a builder; call [`SearchKnowledgeBase::send`] to run it.
202    pub fn search(&self, query: impl Into<String>) -> SearchKnowledgeBase<'a> {
203        SearchKnowledgeBase {
204            client: self.client,
205            query: query.into(),
206            flavor: "public".to_string(),
207            per_page: 50,
208        }
209    }
210
211    /// `POST /api/v1/knowledge_bases/init`, optionally requesting the body
212    /// content for the given answer-translation content ids.
213    async fn init(&self, content_ids: &[u64]) -> Result<KbAssets> {
214        let body = serde_json::json!({ "answer_translation_content_ids": content_ids });
215        let req = self.client.request(Method::POST, INIT_PATH).json(&body);
216        self.client.send_json(req).await
217    }
218}
219
220/// Builder for `POST /api/v1/knowledge_bases/search`.
221#[derive(Debug)]
222pub struct SearchKnowledgeBase<'a> {
223    client: &'a Client,
224    query: String,
225    flavor: String,
226    per_page: u32,
227}
228
229impl SearchKnowledgeBase<'_> {
230    /// Result flavor. `"public"` (the default) searches published,
231    /// publicly-visible answers; `"agent"` also covers answers only visible
232    /// to signed-in agents.
233    pub fn flavor(mut self, flavor: impl Into<String>) -> Self {
234        self.flavor = flavor.into();
235        self
236    }
237
238    /// Maximum number of hits to return (default 50).
239    pub fn per_page(mut self, n: u32) -> Self {
240        self.per_page = n;
241        self
242    }
243
244    /// Run the search.
245    pub async fn send(self) -> Result<Vec<KbSearchHit>> {
246        let body = serde_json::json!({
247            "query": self.query,
248            "flavor": self.flavor,
249            "per_page": self.per_page,
250            "include_subtitle": true,
251        });
252        let req = self.client.request(Method::POST, SEARCH_PATH).json(&body);
253        let resp: SearchResponse = self.client.send_json(req).await?;
254
255        // `details` carries the presentable fields (title, body snippet,
256        // category subtitle, url); `id` there is the answer *translation* id.
257        // Resolve each to its stable answer id via `init` so callers can hand
258        // the id straight to [`KnowledgeBaseApi::get`].
259        let hits = resp.details;
260        if hits.is_empty() {
261            return Ok(Vec::new());
262        }
263
264        let assets = KnowledgeBaseApi::new(self.client).init(&[]).await?;
265        Ok(hits
266            .into_iter()
267            .map(|d| KbSearchHit {
268                answer_id: assets.translations.get(&d.id).map(|t| t.answer_id),
269                translation_id: d.id,
270                title: strip_highlight(d.title),
271                category: d.subtitle,
272                snippet: strip_highlight(d.body),
273                url: d.url,
274            })
275            .collect())
276    }
277}
278
279/// A single Knowledge Base answer, flattened from Zammad's assets bundle.
280#[derive(Debug, Clone)]
281pub struct KbAnswer {
282    /// Numeric answer id (stable; accepted by [`KnowledgeBaseApi::get`]).
283    pub id: u64,
284    /// Answer title, in the Knowledge Base's primary locale.
285    pub title: Option<String>,
286    /// Id of the category the answer belongs to.
287    pub category_id: Option<u64>,
288    /// Title of that category, in the primary locale.
289    pub category: Option<String>,
290    /// Whether the answer has been published.
291    pub published: bool,
292    /// The answer body (HTML). Only populated by [`KnowledgeBaseApi::get`];
293    /// always `None` for [`KnowledgeBaseApi::list`].
294    pub body: Option<String>,
295}
296
297/// Fields for creating a Knowledge Base answer via
298/// [`KnowledgeBaseApi::create_answer`].
299#[derive(Debug, Clone)]
300pub struct NewKbAnswer {
301    /// Category the answer belongs to. The knowledge base and locale are
302    /// resolved from it.
303    pub category_id: u64,
304    /// Answer title.
305    pub title: String,
306    /// Answer body (HTML or plain text).
307    pub body: String,
308    /// Publish the answer immediately (`true`) or leave it a draft (`false`).
309    pub published: bool,
310}
311
312/// A partial update for [`KnowledgeBaseApi::update_answer`]. Every field is
313/// optional; `None` leaves the current value untouched.
314#[derive(Debug, Clone, Default)]
315pub struct KbAnswerPatch {
316    /// New title.
317    pub title: Option<String>,
318    /// New body (HTML or plain text).
319    pub body: Option<String>,
320    /// `Some(true)` publishes, `Some(false)` reverts to a draft, `None` leaves
321    /// the published state unchanged.
322    pub published: Option<bool>,
323}
324
325/// A single full-text search hit.
326#[derive(Debug, Clone)]
327pub struct KbSearchHit {
328    /// Stable answer id, resolved from the matched translation. `None` only if
329    /// the translation could not be found in the structure payload.
330    pub answer_id: Option<u64>,
331    /// Id of the answer *translation* that matched.
332    pub translation_id: u64,
333    /// Answer title (search highlight markers stripped).
334    pub title: Option<String>,
335    /// Category the answer sits under.
336    pub category: Option<String>,
337    /// A snippet of the body around the match (highlight markers stripped).
338    pub snippet: Option<String>,
339    /// Relative help-center URL of the answer.
340    pub url: Option<String>,
341}
342
343// --- wire types -------------------------------------------------------------
344
345/// The `init` assets bundle, with only the maps this module joins on.
346#[derive(Debug, Default, Deserialize)]
347struct KbAssets {
348    #[serde(rename = "KnowledgeBaseAnswer", default)]
349    answers: BTreeMap<u64, RawAnswer>,
350    #[serde(rename = "KnowledgeBaseAnswerTranslation", default)]
351    translations: BTreeMap<u64, RawTranslation>,
352    #[serde(rename = "KnowledgeBaseAnswerTranslationContent", default)]
353    contents: BTreeMap<u64, RawContent>,
354    #[serde(rename = "KnowledgeBaseCategory", default)]
355    categories: BTreeMap<u64, RawCategory>,
356    #[serde(rename = "KnowledgeBaseCategoryTranslation", default)]
357    category_translations: BTreeMap<u64, RawCategoryTranslation>,
358    #[serde(rename = "KnowledgeBaseLocale", default)]
359    locales: BTreeMap<u64, RawLocale>,
360}
361
362impl KbAssets {
363    /// The primary-locale translation for an answer (the first listed).
364    fn primary_translation(&self, raw: &RawAnswer) -> Option<&RawTranslation> {
365        raw.translation_ids
366            .first()
367            .and_then(|tid| self.translations.get(tid))
368    }
369
370    /// The knowledge base a category belongs to.
371    fn kb_of_category(&self, category_id: u64) -> Option<u64> {
372        self.categories
373            .get(&category_id)
374            .and_then(|c| c.knowledge_base_id)
375    }
376
377    /// The primary `kb_locale_id` of a knowledge base (needed to address a
378    /// translation on write). Falls back to that KB's first locale.
379    fn primary_locale_id(&self, kb_id: u64) -> Option<u64> {
380        let mut first = None;
381        for (&id, loc) in &self.locales {
382            if loc.knowledge_base_id != kb_id {
383                continue;
384            }
385            if loc.primary {
386                return Some(id);
387            }
388            first.get_or_insert(id);
389        }
390        first
391    }
392
393    /// Build a [`KbAnswer`]. `body` is `Some(...)` for a fetched answer (the
394    /// inner `Option` being whether a body actually exists), `None` for a
395    /// listing where the body was never requested.
396    fn to_answer(&self, id: u64, raw: &RawAnswer, body: Option<Option<String>>) -> KbAnswer {
397        let title = self.primary_translation(raw).and_then(|t| t.title.clone());
398        let category = raw
399            .category_id
400            .and_then(|cid| self.categories.get(&cid))
401            .and_then(|c| c.translation_ids.first())
402            .and_then(|ctid| self.category_translations.get(ctid))
403            .and_then(|ct| ct.title.clone());
404        KbAnswer {
405            id,
406            title,
407            category_id: raw.category_id,
408            category,
409            published: raw.published_at.is_some(),
410            body: body.flatten(),
411        }
412    }
413}
414
415#[derive(Debug, Deserialize)]
416struct RawAnswer {
417    #[serde(default)]
418    category_id: Option<u64>,
419    #[serde(default)]
420    translation_ids: Vec<u64>,
421    #[serde(default)]
422    published_at: Option<String>,
423}
424
425#[derive(Debug, Deserialize)]
426struct RawTranslation {
427    answer_id: u64,
428    #[serde(default)]
429    title: Option<String>,
430    #[serde(default)]
431    content_id: Option<u64>,
432}
433
434#[derive(Debug, Deserialize)]
435struct RawContent {
436    #[serde(default)]
437    body: Option<String>,
438}
439
440#[derive(Debug, Deserialize)]
441struct RawCategory {
442    #[serde(default)]
443    knowledge_base_id: Option<u64>,
444    #[serde(default)]
445    translation_ids: Vec<u64>,
446}
447
448#[derive(Debug, Deserialize)]
449struct RawLocale {
450    knowledge_base_id: u64,
451    #[serde(default)]
452    primary: bool,
453}
454
455#[derive(Debug, Deserialize)]
456struct RawCategoryTranslation {
457    #[serde(default)]
458    title: Option<String>,
459}
460
461/// The `{ id, assets }` envelope returned by answer create/update.
462#[derive(Debug, Deserialize)]
463struct AnswerWriteResponse {
464    id: u64,
465}
466
467#[derive(Debug, Deserialize)]
468struct SearchResponse {
469    #[serde(default)]
470    details: Vec<SearchDetail>,
471}
472
473#[derive(Debug, Deserialize)]
474struct SearchDetail {
475    id: u64,
476    #[serde(default)]
477    title: Option<String>,
478    #[serde(default)]
479    body: Option<String>,
480    #[serde(default)]
481    subtitle: Option<String>,
482    #[serde(default)]
483    url: Option<String>,
484}
485
486/// Drop the `<em>…</em>` markers Zammad wraps around matched terms in search
487/// highlights, leaving plain text.
488fn strip_highlight(s: Option<String>) -> Option<String> {
489    s.map(|s| s.replace("<em>", "").replace("</em>", ""))
490}