Skip to main content

docbox_search/typesense/
mod.rs

1use crate::{
2    SearchError, SearchIndex,
3    models::{
4        DocumentPage, FileSearchRequest, FileSearchResults, FlattenedItemResult, PageResult,
5        SearchIndexData, SearchRequest, SearchResults, SearchScore, UpdateSearchIndexData,
6    },
7    typesense::{
8        api_key::ApiKeyProvider,
9        models::{
10            GenericSearchResponse, GroupedSearchResponse, SearchResponse, TypesenseDataEntry,
11            TypesenseDataEntryPageV1, TypesenseDataEntryRootV1, TypesenseDataEntryV1,
12            TypesenseEntry,
13        },
14    },
15};
16use docbox_database::{
17    DbTransaction,
18    models::{
19        document_box::{DocumentBoxScopeRaw, DocumentBoxScopeRawRef},
20        file::FileId,
21        folder::FolderId,
22        tenant::Tenant,
23    },
24};
25use docbox_secrets::SecretManager;
26use itertools::Itertools;
27use reqwest::StatusCode;
28use serde::{Deserialize, Serialize};
29use serde_json::json;
30use std::{fmt::Debug, sync::Arc};
31use uuid::Uuid;
32
33pub use api_key::{TypesenseApiKey, TypesenseApiKeyProvider, TypesenseApiKeySecret};
34pub use error::{TypesenseIndexFactoryError, TypesenseSearchError};
35
36pub mod api_key;
37pub mod error;
38mod models;
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
41pub struct TypesenseSearchConfig {
42    pub url: String,
43
44    /// Config provides the API key directly
45    pub api_key: Option<TypesenseApiKey>,
46
47    /// Config provides a secret manager key pointing to the API key
48    pub api_key_secret_name: Option<String>,
49}
50
51impl TypesenseSearchConfig {
52    pub fn from_env() -> Result<Self, TypesenseIndexFactoryError> {
53        let url = std::env::var("DOCBOX_TYPESENSE_URL")
54            .or(std::env::var("TYPESENSE_URL"))
55            .map_err(|_| TypesenseIndexFactoryError::MissingUrl)?;
56
57        let api_key = std::env::var("DOCBOX_TYPESENSE_API_KEY")
58            .or(std::env::var("TYPESENSE_API_KEY"))
59            .map(TypesenseApiKey::new)
60            .ok();
61        let api_key_secret_name = std::env::var("DOCBOX_TYPESENSE_API_KEY_SECRET_NAME")
62            .or(std::env::var("TYPESENSE_API_KEY_SECRET_NAME"))
63            .ok();
64
65        Ok(Self {
66            url,
67            api_key,
68            api_key_secret_name,
69        })
70    }
71}
72
73/// Shared client data (Base URL, API Key provider, ..etc)
74pub struct TypesenseClientData {
75    base_url: String,
76    api_key_provider: TypesenseApiKeyProvider,
77}
78
79#[derive(Clone)]
80pub struct TypesenseIndexFactory {
81    client: reqwest::Client,
82    client_data: Arc<TypesenseClientData>,
83}
84
85impl TypesenseIndexFactory {
86    pub fn from_config(
87        secrets: SecretManager,
88        config: TypesenseSearchConfig,
89    ) -> Result<Self, TypesenseIndexFactoryError> {
90        let api_key_provider = match (config.api_key, config.api_key_secret_name) {
91            (Some(api_key), _) => {
92                tracing::debug!("using typesense api key");
93                TypesenseApiKeyProvider::ApiKey(api_key)
94            }
95            (_, Some(secret_name)) => {
96                tracing::debug!("using secret manager controller typesense api key");
97                TypesenseApiKeyProvider::Secret(TypesenseApiKeySecret::new(secrets, secret_name))
98            }
99            _ => return Err(TypesenseIndexFactoryError::MissingApiKey),
100        };
101
102        let client = reqwest::Client::builder()
103            // Don't try and proxy through the proxy
104            .no_proxy()
105            .build()
106            .map_err(|error| {
107                tracing::error!(?error, " failed to create typesense http client");
108                TypesenseIndexFactoryError::CreateClient
109            })?;
110
111        let client_data = Arc::new(TypesenseClientData {
112            base_url: config.url,
113            api_key_provider,
114        });
115
116        Ok(Self {
117            client,
118            client_data,
119        })
120    }
121
122    pub fn create_search_index(&self, index: String) -> TypesenseIndex {
123        TypesenseIndex {
124            client: self.client.clone(),
125            client_data: self.client_data.clone(),
126            index,
127        }
128    }
129}
130
131#[derive(Clone)]
132pub struct TypesenseIndex {
133    client: reqwest::Client,
134    client_data: Arc<TypesenseClientData>,
135    index: String,
136}
137
138fn escape_typesense_value(input: &str) -> String {
139    // Escape backticks within the text
140    let escaped = input.replace('`', "\\`");
141
142    // Surround the text with backticks
143    format!("`{escaped}`")
144}
145
146impl SearchIndex for TypesenseIndex {
147    async fn create_index(&self) -> Result<(), SearchError> {
148        let api_key = self.client_data.api_key_provider.get_api_key().await?;
149
150        let schema = json!({
151          "name": self.index,
152          "fields": [
153            { "name": "id", "type": "string" },
154
155            { "name": "version", "type": "string", "facet": true },
156            { "name": "entry_type", "type": "string", "facet": true },
157
158            { "name": "document_box", "type": "string", "facet": true },
159            { "name": "folder_id", "type": "string", "facet": true },
160
161            { "name": "item_type", "type": "string", "facet": true },
162            { "name": "item_id", "type": "string", "facet": true },
163            { "name": "name", "type": "string" },
164
165            { "name": "value", "type": "string", "optional": true },
166            { "name": "mime", "type": "string", "optional": true },
167
168            { "name": "created_at", "type": "int64", "facet": true },
169            { "name": "created_by", "type": "string", "optional": true, "facet": true },
170
171            { "name": "page", "type": "int32", "optional": true },
172            { "name": "page_content", "type": "string", "optional": true }
173          ]
174        });
175
176        self.client
177            .post(format!("{}/collections", self.client_data.base_url))
178            .header("x-typesense-api-key", api_key)
179            .json(&schema)
180            .send()
181            .await
182            .map_err(|error| {
183                tracing::error!(?error, "failed to create search index (io)");
184                TypesenseSearchError::CreateIndex
185            })?
186            .error_for_status()
187            .map_err(|error| {
188                tracing::error!(?error, "failed to create search index (response)");
189                TypesenseSearchError::CreateIndex
190            })?;
191
192        Ok(())
193    }
194
195    async fn index_exists(&self) -> Result<bool, SearchError> {
196        let api_key = self.client_data.api_key_provider.get_api_key().await?;
197
198        let response = self
199            .client
200            .get(format!(
201                "{}/collections/{}",
202                self.client_data.base_url, self.index
203            ))
204            .header("x-typesense-api-key", api_key)
205            .send()
206            .await
207            .map_err(|error| {
208                tracing::error!(?error, "failed to get search index (io)");
209                TypesenseSearchError::GetIndex
210            })?;
211
212        if response.status() == StatusCode::NOT_FOUND {
213            return Ok(false);
214        }
215
216        response.error_for_status().map_err(|error| {
217            tracing::error!(?error, "failed to get search index (response)");
218            TypesenseSearchError::GetIndex
219        })?;
220
221        Ok(true)
222    }
223
224    async fn delete_index(&self) -> Result<(), SearchError> {
225        let api_key = self.client_data.api_key_provider.get_api_key().await?;
226
227        let response = self
228            .client
229            .delete(format!(
230                "{}/collections/{}",
231                self.client_data.base_url, self.index
232            ))
233            .header("x-typesense-api-key", api_key)
234            .send()
235            .await
236            .map_err(|error| {
237                tracing::error!(?error, "failed to delete search index (io)");
238                TypesenseSearchError::DeleteIndex
239            })?;
240
241        // Gracefully handle the index already not existing
242        if response.status() == StatusCode::NOT_FOUND {
243            return Ok(());
244        }
245
246        response.error_for_status().map_err(|error| {
247            tracing::error!(?error, "failed to delete search index (response)");
248            TypesenseSearchError::DeleteIndex
249        })?;
250
251        Ok(())
252    }
253
254    async fn search_index_file(
255        &self,
256        scope: &DocumentBoxScopeRaw,
257        file_id: FileId,
258        query: FileSearchRequest,
259    ) -> Result<FileSearchResults, SearchError> {
260        let api_key = self.client_data.api_key_provider.get_api_key().await?;
261
262        let offset = query.offset.unwrap_or(0);
263        let limit = query.limit.unwrap_or(50);
264        let query = query.query.unwrap_or_default();
265        let filter_by = format!(
266            r#"document_box:="{}"&&item_id:="{}"&&entry_type:="Page""#,
267            escape_typesense_value(scope),
268            // UUID does not need to be escaped
269            &file_id
270        );
271
272        let query_json = json!({
273            "searches": [
274                {
275                    "collection": self.index,
276                    "q": query,
277                    "query_by": "page_content",
278                    "offset": offset,
279                    "limit": limit,
280                    "filter_by": filter_by,
281                    "exclude_fields": "page_content",
282                    "highlight_fields": "page_content",
283                    "highlight_start_tag": "<em>",
284                    "highlight_end_tag": "</em>",
285                    "highlight_affix_num_tokens": 15,
286                }
287            ]
288        });
289
290        let response = self
291            .client
292            .post(format!("{}/multi_search", self.client_data.base_url))
293            .header("x-typesense-api-key", api_key)
294            .json(&query_json)
295            .send()
296            .await
297            .map_err(|error| {
298                tracing::error!(?error, "failed to query typesense multi_search");
299                TypesenseSearchError::SearchIndex
300            })?;
301
302        if let Err(error) = response.error_for_status_ref() {
303            let body = response.text().await;
304            tracing::error!(?error, ?body, "failed to get search results");
305            return Err(TypesenseSearchError::SearchIndex.into());
306        }
307
308        let search: SearchResponse<GenericSearchResponse> =
309            response.json().await.map_err(|error| {
310                tracing::error!(?error, "failed to parse search response JSON");
311                TypesenseSearchError::SearchIndex
312            })?;
313
314        let search = search
315            .results
316            .into_iter()
317            .next()
318            .ok_or(TypesenseSearchError::MissingSearchResult)?;
319
320        let total_hits = search.found;
321        let results: Vec<PageResult> = search
322            .hits
323            .into_iter()
324            .filter_map(|hit| match hit.document {
325                TypesenseDataEntry::V1(TypesenseDataEntryV1::Page(page)) => {
326                    let highlighted = hit
327                        .highlights
328                        .into_iter()
329                        .find(|value| value.field == "page_content")
330                        .map(|value| value.snippet)?;
331
332                    Some(PageResult {
333                        page: page.page,
334                        matches: vec![highlighted],
335                    })
336                }
337                _ => None,
338            })
339            .collect();
340
341        Ok(FileSearchResults {
342            total_hits,
343            results,
344        })
345    }
346
347    async fn search_index(
348        &self,
349        scopes: &[DocumentBoxScopeRaw],
350        query: SearchRequest,
351        folder_children: Option<Vec<FolderId>>,
352    ) -> Result<SearchResults, SearchError> {
353        let mut query_by = Vec::new();
354
355        // Query file name
356        if query.include_name {
357            query_by.push("name");
358        }
359
360        // Querying within content (link value and page content)
361        if query.include_content {
362            query_by.push("value");
363            query_by.push("page_content");
364        }
365
366        let filter_by = create_search_filters(scopes, &query, folder_children);
367        let search_query = query.query.unwrap_or_default();
368
369        // Must query at least one field
370        if query_by.is_empty() {
371            // When specifying a query at least one field must be specified
372            if !search_query.is_empty() && !filter_by.is_empty() {
373                return Err(TypesenseSearchError::MissingQueryBy.into());
374            }
375
376            // For facet only queries the name is used as a dummy value
377            query_by.push("name");
378        }
379
380        let has_wildcard = scopes.iter().any(|scope| scope.ends_with('*'));
381
382        let max_filter_by_candidates = if has_wildcard {
383            // Allow filtering over a much larger set when selecting a wildcard scope
384            10_000
385        } else {
386            4
387        };
388
389        let size = query.size.unwrap_or(50);
390        let offset = query.offset.unwrap_or(0);
391
392        let max_pages = query.max_pages.unwrap_or(3);
393
394        let query_by = query_by.join(",");
395
396        let query_json = json!({
397            "searches": [
398                {
399                    "collection": self.index,
400                    "q": search_query,
401                    "query_by": query_by,
402                    "group_by": "item_id",
403                    "group_limit": max_pages,
404                    "offset": offset,
405                    "limit": size,
406                    "filter_by": filter_by,
407                    "exclude_fields": "page_content",
408                    "highlight_fields": "name,value,page_content",
409                    "highlight_start_tag": "<em>",
410                    "highlight_end_tag": "</em>",
411                    "max_filter_by_candidates": max_filter_by_candidates
412                }
413            ]
414        });
415
416        tracing::debug!(?query_json, "performing search query");
417
418        let api_key = self.client_data.api_key_provider.get_api_key().await?;
419
420        let response = self
421            .client
422            .post(format!("{}/multi_search", self.client_data.base_url))
423            .header("x-typesense-api-key", api_key)
424            .json(&query_json)
425            .send()
426            .await
427            .map_err(|error| {
428                tracing::error!(?error, "failed to query typesense multi_search");
429                TypesenseSearchError::SearchIndex
430            })?;
431
432        if let Err(error) = response.error_for_status_ref() {
433            let body = response.text().await;
434            let query = serde_json::to_string(&query_json);
435            tracing::error!(?error, ?body, ?query, "failed to get search results");
436            return Err(TypesenseSearchError::SearchIndex.into());
437        }
438
439        let response: serde_json::Value = response.json().await.map_err(|error| {
440            tracing::error!(?error, "failed to parse search response JSON");
441            TypesenseSearchError::SearchIndex
442        })?;
443        tracing::debug!(?response);
444
445        let search: SearchResponse<GroupedSearchResponse> = serde_json::from_value(response)
446            .map_err(|error| {
447                tracing::error!(?error, "failed to parse search response JSON");
448                TypesenseSearchError::SearchIndex
449            })?;
450        let search = search
451            .results
452            .into_iter()
453            .next()
454            .ok_or(TypesenseSearchError::MissingSearchResult)?;
455
456        let total_hits = search.found;
457
458        let results = search
459            .grouped_hits
460            .into_iter()
461            .filter_map(|group| {
462                let root = group.hits.first().map(|value| &value.document)?;
463
464                match root {
465                    TypesenseDataEntry::V1(TypesenseDataEntryV1::Root(root))
466                    | TypesenseDataEntry::V1(TypesenseDataEntryV1::Page(
467                        TypesenseDataEntryPageV1 { root, .. },
468                    )) => {
469                        let group_score = group
470                            .hits
471                            .iter()
472                            .map(|hit| hit.text_match)
473                            .max()
474                            .unwrap_or_default();
475
476                        // Check for name matches
477                        let name_match = group.hits.iter().any(|hit| {
478                            hit.highlights
479                                .iter()
480                                .any(|highlight| highlight.field == "name")
481                        });
482
483                        // Check for content matches
484                        let content_match = group.hits.iter().any(|hit| {
485                            hit.highlights.iter().any(|highlight| {
486                                highlight.field == "value" || highlight.field == "page_content"
487                            })
488                        });
489
490                        let page_matches: Vec<PageResult> = group
491                            .hits
492                            .iter()
493                            .filter_map(|hit| match &hit.document {
494                                TypesenseDataEntry::V1(TypesenseDataEntryV1::Page(page)) => {
495                                    let highlighted = hit
496                                        .highlights
497                                        .iter()
498                                        .find(|value| value.field == "page_content")
499                                        .map(|value| value.snippet.to_string())?;
500
501                                    Some(PageResult {
502                                        page: page.page,
503                                        matches: vec![highlighted],
504                                    })
505                                }
506                                _ => None,
507                            })
508                            .collect();
509
510                        Some(FlattenedItemResult {
511                            item_ty: root.ty,
512                            item_id: root.item_id,
513                            document_box: root.document_box.clone(),
514                            page_matches,
515                            total_hits: group.found,
516                            score: SearchScore::Integer(group_score),
517                            name_match,
518                            content_match,
519                        })
520                    }
521                }
522            })
523            .collect();
524
525        Ok(SearchResults {
526            total_hits,
527            results,
528        })
529    }
530
531    async fn add_data(&self, data: Vec<SearchIndexData>) -> Result<(), SearchError> {
532        let mut documents = Vec::new();
533
534        for data in data {
535            let root = TypesenseDataEntryRootV1 {
536                document_box: data.document_box,
537                folder_id: data.folder_id,
538                ty: data.ty,
539                item_id: data.item_id,
540                created_at: data.created_at.timestamp(),
541                created_by: data.created_by,
542                value: data.content,
543                mime: data.mime,
544                name: data.name,
545            };
546
547            // When its a file with page data
548            if let Some(pages) = data.pages {
549                // Add a new entry for each page
550                for page in pages {
551                    documents.push(create_item_page(&root, page));
552                }
553            }
554
555            // Create the root document
556            documents.push(TypesenseEntry {
557                id: Uuid::new_v4(),
558                entry: TypesenseDataEntry::V1(TypesenseDataEntryV1::Root(root)),
559            });
560        }
561
562        self.bulk_add_documents(documents).await?;
563
564        Ok(())
565    }
566
567    async fn update_data(
568        &self,
569        item_id: Uuid,
570        data: UpdateSearchIndexData,
571    ) -> Result<(), SearchError> {
572        // Update all the existing items so they have the current root data
573        self.update_item_roots(item_id, &data).await?;
574
575        if let Some(pages) = data.pages {
576            // Delete all page based documents
577            self.delete_item_pages(item_id).await?;
578
579            // Resolve the root document
580            let root = self
581                .get_item_root(item_id)
582                .await?
583                .ok_or(TypesenseSearchError::MissingRootEntry)?;
584
585            // Create an updated version of the root document to
586            // use on the added pages
587            let updated_root = TypesenseDataEntryRootV1 {
588                document_box: root.document_box.clone(),
589                folder_id: data.folder_id,
590                ty: root.ty,
591                item_id: root.item_id,
592                name: data.name.clone(),
593                value: data.content.clone(),
594                mime: root.mime.clone(),
595                created_at: root.created_at,
596                created_by: root.created_by.clone(),
597            };
598
599            // Create the documents for the pages
600            let documents = pages
601                .into_iter()
602                .map(|page| create_item_page(&updated_root, page))
603                .collect();
604
605            // Bulk insert the created documents
606            self.bulk_add_documents(documents).await?;
607        }
608
609        Ok(())
610    }
611
612    async fn delete_data(&self, id: Uuid) -> Result<(), SearchError> {
613        let api_key = self.client_data.api_key_provider.get_api_key().await?;
614
615        self.client
616            .delete(format!(
617                "{}/collections/{}/documents",
618                self.client_data.base_url, self.index
619            ))
620            .header("x-typesense-api-key", api_key)
621            .query(&[("filter_by", format!(r#"item_id:="{id}""#))])
622            .send()
623            .await
624            .map_err(|error| {
625                tracing::error!(?error, "failed to delete data (request)");
626                TypesenseSearchError::DeleteDocuments
627            })?
628            .error_for_status()
629            .map_err(|error| {
630                tracing::error!(?error, "failed to delete data (response)");
631                TypesenseSearchError::DeleteDocuments
632            })?;
633        Ok(())
634    }
635
636    async fn delete_by_scope(&self, scope: DocumentBoxScopeRawRef<'_>) -> Result<(), SearchError> {
637        let api_key = self.client_data.api_key_provider.get_api_key().await?;
638
639        self.client
640            .delete(format!(
641                "{}/collections/{}/documents",
642                self.client_data.base_url, self.index
643            ))
644            .header("x-typesense-api-key", api_key)
645            .query(&[("filter_by", format!(r#"document_box:="{scope}""#))])
646            .send()
647            .await
648            .map_err(|error| {
649                tracing::error!(?error, "failed to delete data by scope (request)");
650                TypesenseSearchError::DeleteDocuments
651            })?
652            .error_for_status()
653            .map_err(|error| {
654                tracing::error!(?error, "failed to delete data by scope (response)");
655                TypesenseSearchError::DeleteDocuments
656            })?;
657        Ok(())
658    }
659
660    async fn get_pending_migrations(
661        &self,
662        _applied_names: Vec<String>,
663    ) -> Result<Vec<String>, SearchError> {
664        Ok(Vec::new())
665    }
666
667    async fn apply_migration(
668        &self,
669        _tenant: &Tenant,
670        _root_t: &mut DbTransaction<'_>,
671        _t: &mut DbTransaction<'_>,
672        _name: &str,
673    ) -> Result<(), SearchError> {
674        Ok(())
675    }
676}
677
678impl TypesenseIndex {
679    /// Bulk insert typesense documents
680    async fn bulk_add_documents(
681        &self,
682        entries: Vec<TypesenseEntry>,
683    ) -> Result<(), TypesenseSearchError> {
684        // Encode entries into newline delimitated encoded JSON strings
685        let mut bulk_data = String::new();
686        for document in entries {
687            let value = serde_json::to_string(&document).map_err(|error| {
688                tracing::error!(?error, "failed to serialize a document");
689                TypesenseSearchError::BulkAddDocuments
690            })?;
691            bulk_data.push_str(&value);
692            bulk_data.push('\n');
693        }
694
695        let api_key = self.client_data.api_key_provider.get_api_key().await?;
696
697        self.client
698            .post(format!(
699                "{}/collections/{}/documents/import",
700                self.client_data.base_url, self.index
701            ))
702            .header("x-typesense-api-key", api_key)
703            .body(bulk_data)
704            .send()
705            .await
706            .map_err(|error| {
707                tracing::error!(?error, "failed to send import documents (request)");
708                TypesenseSearchError::BulkAddDocuments
709            })?
710            .error_for_status()
711            .map_err(|error| {
712                tracing::error!(?error, "error status when importing documents (response)");
713                TypesenseSearchError::BulkAddDocuments
714            })?;
715
716        Ok(())
717    }
718
719    /// Deletes all "Page" item types for a specific `item_id`
720    async fn delete_item_pages(&self, item_id: Uuid) -> Result<(), TypesenseSearchError> {
721        let api_key = self.client_data.api_key_provider.get_api_key().await?;
722
723        self.client
724            .delete(format!(
725                "{}/collections/{}/documents",
726                self.client_data.base_url, self.index
727            ))
728            .header("x-typesense-api-key", api_key)
729            .query(&[(
730                "filter_by",
731                format!(r#"item_id:="{item_id}"&&entry_type="Page""#),
732            )])
733            .send()
734            .await
735            .map_err(|error| {
736                tracing::error!(?error, "failed to send delete documents (request)");
737                TypesenseSearchError::DeleteDocuments
738            })?
739            .error_for_status()
740            .map_err(|error| {
741                tracing::error!(?error, "failed to send delete documents (response)");
742                TypesenseSearchError::DeleteDocuments
743            })?;
744
745        Ok(())
746    }
747
748    /// Updates the root portion of all documents for the provided item
749    async fn update_item_roots(
750        &self,
751        item_id: Uuid,
752        update: &UpdateSearchIndexData,
753    ) -> Result<(), TypesenseSearchError> {
754        let api_key = self.client_data.api_key_provider.get_api_key().await?;
755
756        let request = json!({
757            "folder_id": update.folder_id,
758            "name": update.name,
759            "value": update.content,
760        });
761
762        // Update all the existing items so they have the current root data
763        self.client
764            .patch(format!(
765                "{}/collections/{}/documents",
766                self.client_data.base_url, self.index
767            ))
768            .header("x-typesense-api-key", api_key)
769            .query(&[("filter_by", format!(r#"item_id:="{item_id}""#))])
770            .json(&request)
771            .send()
772            .await
773            .map_err(|error| {
774                tracing::error!(?error, "failed to send update documents (request)");
775                TypesenseSearchError::UpdateDocument
776            })?
777            .error_for_status()
778            .map_err(|error| {
779                tracing::error!(?error, "failed to send update documents (response)");
780                TypesenseSearchError::UpdateDocument
781            })?;
782
783        Ok(())
784    }
785
786    /// Get the "root" item document for the provided `item_id`
787    async fn get_item_root(
788        &self,
789        item_id: Uuid,
790    ) -> Result<Option<TypesenseDataEntryRootV1>, TypesenseSearchError> {
791        let api_key = self.client_data.api_key_provider.get_api_key().await?;
792
793        let response: GenericSearchResponse = self
794            .client
795            .get(format!(
796                "{}/collections/{}/documents/search",
797                self.client_data.base_url, self.index
798            ))
799            .header("x-typesense-api-key", api_key)
800            .query(&[(
801                "filter_by",
802                format!(r#"item_id:="{item_id}"&&entry_type=Root"#),
803            )])
804            .send()
805            .await
806            .map_err(|error| {
807                tracing::error!(?error, "failed to query documents search (request)");
808                TypesenseSearchError::GetDocument
809            })?
810            .error_for_status()
811            .map_err(|error| {
812                tracing::error!(?error, "failed to query documents search (response)");
813                TypesenseSearchError::GetDocument
814            })?
815            .json()
816            .await
817            .map_err(|error| {
818                tracing::error!(?error, "failed to query documents search (response json)");
819                TypesenseSearchError::GetDocument
820            })?;
821
822        let item = response
823            .hits
824            .into_iter()
825            .filter_map(|hit| match hit.document {
826                TypesenseDataEntry::V1(TypesenseDataEntryV1::Root(root)) => Some(root),
827                _ => None,
828            })
829            .next();
830
831        Ok(item)
832    }
833}
834
835fn create_item_page(root: &TypesenseDataEntryRootV1, page: DocumentPage) -> TypesenseEntry {
836    TypesenseEntry {
837        id: Uuid::new_v4(),
838        entry: TypesenseDataEntry::V1(TypesenseDataEntryV1::Page(TypesenseDataEntryPageV1 {
839            root: root.clone(),
840            page: page.page,
841            page_content: Some(page.content),
842        })),
843    }
844}
845
846/// Create the required typesense search filters for the `query`
847fn create_search_filters(
848    scopes: &[DocumentBoxScopeRaw],
849    query: &SearchRequest,
850    folder_children: Option<Vec<FolderId>>,
851) -> String {
852    let mut filter_parts = Vec::new();
853
854    // Add a filter for the required scopes
855    {
856        let scopes = scopes
857            .iter()
858            .map(|value| escape_typesense_value(value))
859            .join(", ");
860
861        filter_parts.push(format!("document_box:=[{scopes}]"));
862    }
863
864    // Filter to children of allowed folders
865    if let Some(folder_children) = folder_children
866        && !folder_children.is_empty()
867    {
868        let ids = folder_children
869            .into_iter()
870            // No need to escape UUIDs
871            .map(|value| value.to_string())
872            .join(", ");
873
874        filter_parts.push(format!("folder_id:=[{ids}]"));
875    }
876
877    if let Some(range) = query.created_at.as_ref() {
878        if let Some(start) = range.start {
879            let start = start.timestamp();
880            filter_parts.push(format!(r#"created_at:>{start}"#));
881        }
882
883        if let Some(end) = range.end {
884            let end = end.timestamp();
885            filter_parts.push(format!(r#"created_at:<{end}"#));
886        }
887    }
888
889    if let Some(created_by) = query.created_by.as_ref() {
890        filter_parts.push(format!(
891            r#"created_by:="{}""#,
892            // User ID's must be escaped
893            escape_typesense_value(created_by)
894        ));
895    }
896
897    if let Some(folder_id) = query.folder_id {
898        filter_parts.push(format!(r#"folder_id:="{folder_id}""#));
899    }
900
901    filter_parts.join("&&")
902}