1use crate::{
4 error::{HttpCommonError, HttpErrorResponse, HttpResult, HttpStatusResult},
5 middleware::tenant::{TenantDb, TenantParams, TenantSearch, TenantStorage},
6 models::admin::{TenantDocumentBoxesRequest, TenantDocumentBoxesResponse, TenantStatsResponse},
7};
8use axum::{Extension, Json, http::StatusCode};
9use axum_valid::Garde;
10use docbox_core::{
11 database::{
12 DatabasePoolCache,
13 models::{
14 document_box::{DocumentBox, WithScope},
15 file::File,
16 folder::Folder,
17 link::Link,
18 },
19 },
20 document_box::search_document_box::{ResolvedSearchResult, search_document_boxes_admin},
21 processing::ProcessingLayer,
22 purge::purge_expired_presigned_tasks::purge_expired_presigned_tasks,
23 search::models::{AdminSearchRequest, AdminSearchResultResponse, SearchResultItem},
24 storage::StorageLayerFactory,
25 tenant::tenant_cache::TenantCache,
26};
27use std::sync::Arc;
28use tokio::join;
29
30pub const ADMIN_TAG: &str = "Admin";
31
32#[utoipa::path(
37 post,
38 operation_id = "admin_tenant_boxes",
39 tag = ADMIN_TAG,
40 path = "/admin/boxes",
41 responses(
42 (status = 201, description = "Searched successfully", body = TenantDocumentBoxesResponse),
43 (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
44 (status = 500, description = "Internal server error", body = HttpErrorResponse)
45 ),
46 params(TenantParams)
47)]
48#[tracing::instrument(skip_all, fields(?req))]
49pub async fn tenant_boxes(
50 TenantDb(db): TenantDb,
51 Garde(Json(req)): Garde<Json<TenantDocumentBoxesRequest>>,
52) -> HttpResult<TenantDocumentBoxesResponse> {
53 let offset = req.offset.unwrap_or(0);
54 let limit = req.size.unwrap_or(100);
55
56 let (document_boxes, total) = match req.query {
57 Some(query) if !query.is_empty() => {
58 let mut query = query
60 .replace("*", "%")
62 .replace("_", "\\_");
64
65 let has_wildcard = query.chars().any(|char| matches!(char, '*' | '%'));
66
67 if !has_wildcard {
69 query.push('%');
70 }
71
72 let document_boxes = DocumentBox::search_query(&db, &query, offset, limit as u64)
73 .await
74 .map_err(|error| {
75 tracing::error!(?error, "failed to query document boxes");
76 HttpCommonError::ServerError
77 })?;
78
79 let total = DocumentBox::search_total(&db, &query)
80 .await
81 .map_err(|error| {
82 tracing::error!(?error, "failed to query document boxes total");
83 HttpCommonError::ServerError
84 })?;
85
86 (document_boxes, total)
87 }
88 _ => {
89 let document_boxes = DocumentBox::query(&db, offset, limit as u64)
90 .await
91 .map_err(|error| {
92 tracing::error!(?error, "failed to query document boxes");
93 HttpCommonError::ServerError
94 })?;
95
96 let total = DocumentBox::total(&db).await.map_err(|error| {
97 tracing::error!(?error, "failed to query document boxes total");
98 HttpCommonError::ServerError
99 })?;
100
101 (document_boxes, total)
102 }
103 };
104
105 Ok(Json(TenantDocumentBoxesResponse {
106 results: document_boxes,
107 total,
108 }))
109}
110
111#[utoipa::path(
116 get,
117 operation_id = "admin_tenant_stats",
118 tag = ADMIN_TAG,
119 path = "/admin/tenant-stats",
120 responses(
121 (status = 201, description = "Got stats successfully", body = TenantStatsResponse),
122 (status = 500, description = "Internal server error", body = HttpErrorResponse)
123 ),
124 params(TenantParams)
125)]
126#[tracing::instrument(skip_all)]
127pub async fn tenant_stats(TenantDb(db): TenantDb) -> HttpResult<TenantStatsResponse> {
128 let total_files_future = File::total_count(&db);
129 let total_links_future = Link::total_count(&db);
130 let total_folders_future = Folder::total_count(&db);
131 let file_size_future = File::total_size(&db);
132
133 let (total_files, total_links, total_folders, file_size) = join!(
134 total_files_future,
135 total_links_future,
136 total_folders_future,
137 file_size_future
138 );
139
140 let total_files = total_files.map_err(|error| {
141 tracing::error!(?error, "failed to query tenant total files");
142 HttpCommonError::ServerError
143 })?;
144
145 let total_links = total_links.map_err(|error| {
146 tracing::error!(?error, "failed to query tenant total links");
147 HttpCommonError::ServerError
148 })?;
149
150 let total_folders = total_folders.map_err(|error| {
151 tracing::error!(?error, "failed to query tenant total folders");
152 HttpCommonError::ServerError
153 })?;
154
155 let file_size = file_size.map_err(|error| {
156 tracing::error!(?error, "failed to query tenant files size");
157 HttpCommonError::ServerError
158 })?;
159
160 Ok(Json(TenantStatsResponse {
161 total_files,
162 total_folders,
163 total_links,
164 file_size,
165 }))
166}
167
168#[utoipa::path(
175 post,
176 operation_id = "admin_search_tenant",
177 tag = ADMIN_TAG,
178 path = "/admin/search",
179 responses(
180 (status = 201, description = "Searched successfully", body = AdminSearchResultResponse),
181 (status = 400, description = "Malformed or invalid request not meeting validation requirements", body = HttpErrorResponse),
182 (status = 500, description = "Internal server error", body = HttpErrorResponse)
183 ),
184 params(TenantParams)
185)]
186#[tracing::instrument(skip_all, fields(?req))]
187pub async fn search_tenant(
188 TenantDb(db): TenantDb,
189 TenantSearch(search): TenantSearch,
190 Garde(Json(req)): Garde<Json<AdminSearchRequest>>,
191) -> HttpResult<AdminSearchResultResponse> {
192 if req.scopes.is_empty() {
194 return Ok(Json(AdminSearchResultResponse {
195 total_hits: 0,
196 results: vec![],
197 }));
198 }
199
200 let resolved = search_document_boxes_admin(&db, &search, req)
201 .await
202 .map_err(|error| {
203 tracing::error!(?error, "failed to perform admin search");
204 HttpCommonError::ServerError
205 })?;
206
207 let out: Vec<WithScope<SearchResultItem>> = resolved
208 .results
209 .into_iter()
210 .map(|ResolvedSearchResult { result, data, path }| WithScope {
211 data: SearchResultItem {
212 path,
213 score: result.score,
214 data,
215 page_matches: result.page_matches,
216 total_hits: result.total_hits,
217 name_match: result.name_match,
218 content_match: result.content_match,
219 },
220 scope: result.document_box,
221 })
222 .collect();
223
224 Ok(Json(AdminSearchResultResponse {
225 total_hits: resolved.total_hits,
226 results: out,
227 }))
228}
229
230#[utoipa::path(
240 post,
241 operation_id = "admin_reprocess_octet_stream_files",
242 tag = ADMIN_TAG,
243 path = "/admin/reprocess-octet-stream-files",
244 responses(
245 (status = 204, description = "Reprocessed successfully", body = AdminSearchResultResponse),
246 (status = 500, description = "Internal server error", body = HttpErrorResponse)
247 ),
248 params(TenantParams)
249)]
250#[tracing::instrument(skip_all)]
251pub async fn reprocess_octet_stream_files_tenant(
252 TenantDb(db): TenantDb,
253 TenantSearch(search): TenantSearch,
254 TenantStorage(storage): TenantStorage,
255 Extension(processing): Extension<ProcessingLayer>,
256) -> HttpStatusResult {
257 docbox_core::files::reprocess_octet_stream_files::reprocess_octet_stream_files(
258 &db,
259 &search,
260 &storage,
261 &processing,
262 )
263 .await
264 .map_err(|error| {
265 tracing::error!(?error, "failed to reprocess octet-stream files");
266 HttpCommonError::ServerError
267 })?;
268
269 Ok(StatusCode::NO_CONTENT)
270}
271
272#[utoipa::path(
279 post,
280 operation_id = "admin_rebuild_search_index",
281 tag = ADMIN_TAG,
282 path = "/admin/rebuild-search-index",
283 responses(
284 (status = 204, description = "Rebuilt successfully", body = ()),
285 (status = 500, description = "Internal server error", body = HttpErrorResponse)
286 ),
287 params(TenantParams)
288)]
289#[tracing::instrument(skip_all)]
290pub async fn rebuild_search_index_tenant(
291 TenantDb(db): TenantDb,
292 TenantSearch(search): TenantSearch,
293 TenantStorage(storage): TenantStorage,
294) -> HttpStatusResult {
295 docbox_core::tenant::rebuild_tenant_index::rebuild_tenant_index(&db, &search, &storage)
296 .await
297 .map_err(|error| {
298 tracing::error!(?error, "failed to rebuilt tenant search index");
299 HttpCommonError::ServerError
300 })?;
301
302 Ok(StatusCode::NO_CONTENT)
303}
304
305#[utoipa::path(
311 post,
312 operation_id = "admin_flush_database_pool_cache",
313 tag = ADMIN_TAG,
314 path = "/admin/flush-db-cache",
315 responses(
316 (status = 204, description = "Database cache flushed"),
317 )
318)]
319pub async fn flush_database_pool_cache(
320 Extension(db_cache): Extension<Arc<DatabasePoolCache>>,
321) -> HttpStatusResult {
322 db_cache.flush().await;
323 Ok(StatusCode::NO_CONTENT)
324}
325
326#[utoipa::path(
332 post,
333 operation_id = "admin_flush_tenant_cache",
334 tag = ADMIN_TAG,
335 path = "/admin/flush-tenant-cache",
336 responses(
337 (status = 204, description = "Tenant cache flushed"),
338 )
339)]
340pub async fn flush_tenant_cache(
341 Extension(tenant_cache): Extension<Arc<TenantCache>>,
342) -> HttpStatusResult {
343 tenant_cache.flush().await;
344 Ok(StatusCode::NO_CONTENT)
345}
346
347#[utoipa::path(
352 post,
353 operation_id = "admin_purge_expired_presigned_tasks",
354 tag = ADMIN_TAG,
355 path = "/admin/purge-expired-presigned-tasks",
356 responses(
357 (status = 204, description = "Database cache flushed"),
358 (status = 500, description = "Failed to purge presigned cache", body = HttpErrorResponse),
359 )
360)]
361pub async fn http_purge_expired_presigned_tasks(
362 Extension(db_cache): Extension<Arc<DatabasePoolCache>>,
363 Extension(storage_factory): Extension<StorageLayerFactory>,
364) -> HttpStatusResult {
365 purge_expired_presigned_tasks(db_cache, storage_factory)
366 .await
367 .map_err(|_| HttpCommonError::ServerError)?;
368
369 Ok(StatusCode::NO_CONTENT)
370}