more multi-tenancy
This commit is contained in:
@@ -7,7 +7,7 @@ use std::{
|
||||
use axum::extract::{Json, Multipart, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use diesel::dsl::exists;
|
||||
use diesel::{prelude::*, result::DatabaseErrorKind, select, PgConnection};
|
||||
use reqwest::Client;
|
||||
@@ -31,7 +31,13 @@ use crate::schema::{
|
||||
document_tags, document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::storage_paths::document_version_object_key;
|
||||
use crate::utils::{
|
||||
db::{no_content, validate_bulk_ids, IntoJsonResponse},
|
||||
http::inline_content_disposition,
|
||||
storage_paths::document_version_object_key,
|
||||
time::to_iso,
|
||||
validation::ensure_exists,
|
||||
};
|
||||
|
||||
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
|
||||
const QUICKWIT_MAX_HITS: usize = 200;
|
||||
@@ -45,27 +51,6 @@ fn is_valid_correspondent_role(role: &str) -> bool {
|
||||
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
|
||||
}
|
||||
|
||||
fn inline_content_disposition(filename: &str) -> Option<String> {
|
||||
if filename.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sanitized: String = filename
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'"' | '\\' => '_',
|
||||
_ => ch,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let encoded =
|
||||
percent_encoding::utf8_percent_encode(&sanitized, percent_encoding::NON_ALPHANUMERIC);
|
||||
Some(format!(
|
||||
"inline; filename=\"{}\"; filename*=UTF-8''{}",
|
||||
sanitized, encoded
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DocumentListQuery {
|
||||
pub folder_id: Option<Uuid>,
|
||||
@@ -441,13 +426,13 @@ pub async fn list_documents(
|
||||
.quickwit_endpoint
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal("quickwit endpoint not configured"))?;
|
||||
let index = state
|
||||
.config
|
||||
let tenant = state.tenants.get_by_id(tenant_id)?;
|
||||
let index = tenant
|
||||
.quickwit_index
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal("quickwit index not configured"))?;
|
||||
.ok_or_else(|| AppError::internal("quickwit index not configured for tenant"))?;
|
||||
|
||||
let ids = quickwit_search(endpoint, index, query_str)
|
||||
let ids = quickwit_search(endpoint, index, tenant_id, query_str)
|
||||
.await
|
||||
.map_err(|err| AppError::internal(format!("quickwit search failed: {err}")))?;
|
||||
|
||||
@@ -850,12 +835,7 @@ pub async fn reanalyze_selected_documents(
|
||||
force,
|
||||
} = payload;
|
||||
|
||||
if document_ids.is_empty() {
|
||||
return Err(AppError::bad_request("document_ids must not be empty"));
|
||||
}
|
||||
|
||||
document_ids.sort();
|
||||
document_ids.dedup();
|
||||
validate_bulk_ids(&mut document_ids, "document_ids")?;
|
||||
|
||||
let targets: Vec<(Uuid, Uuid)> = documents::table
|
||||
.filter(documents::id.eq_any(&document_ids))
|
||||
@@ -962,10 +942,11 @@ pub async fn get_document_asset(
|
||||
.checked_add((PRESIGNED_URL_EXPIRY_SECONDS as i64) * 1000)
|
||||
.ok_or_else(|| AppError::internal("failed to compute expiry timestamp"))?;
|
||||
|
||||
let storage = state.storage_for_tenant(tenant_id)?;
|
||||
|
||||
let mut object_responses = Vec::with_capacity(objects.len());
|
||||
for object in objects {
|
||||
let url = state
|
||||
.storage
|
||||
let url = storage
|
||||
.presign_get_object(
|
||||
&object.s3_key,
|
||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||
@@ -1008,8 +989,9 @@ pub async fn download_document(
|
||||
.find(doc.current_version_id)
|
||||
.first(&mut conn)?;
|
||||
|
||||
let presigned_url = state
|
||||
.storage
|
||||
let storage = state.storage_for_tenant(tenant_id)?;
|
||||
|
||||
let presigned_url = storage
|
||||
.presign_get_object(
|
||||
&version.s3_key,
|
||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||
@@ -1065,8 +1047,9 @@ pub async fn download_with_token(
|
||||
|
||||
drop(conn);
|
||||
|
||||
let presigned_url = state
|
||||
.storage
|
||||
let storage = state.storage_for_tenant(claims.tenant_id)?;
|
||||
|
||||
let presigned_url = storage
|
||||
.presign_get_object(
|
||||
&version.s3_key,
|
||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||
@@ -1269,7 +1252,8 @@ pub async fn bulk_move_documents(
|
||||
))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
Ok((StatusCode::OK, Json(BulkMoveResponse { updated })))
|
||||
let body = BulkMoveResponse { updated };
|
||||
Ok((StatusCode::OK, body.into_json()?))
|
||||
}
|
||||
|
||||
pub async fn assign_correspondents(
|
||||
@@ -1281,7 +1265,7 @@ pub async fn assign_correspondents(
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<AssignCorrespondentsRequest>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
) -> AppResult<StatusCode> {
|
||||
if payload.assignments.is_empty() {
|
||||
return Err(AppError::bad_request("assignments must not be empty"));
|
||||
}
|
||||
@@ -1360,7 +1344,7 @@ pub async fn assign_correspondents(
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
no_content()
|
||||
}
|
||||
|
||||
pub async fn bulk_assign_correspondents(
|
||||
@@ -1372,16 +1356,12 @@ pub async fn bulk_assign_correspondents(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<BulkCorrespondentsRequest>,
|
||||
) -> AppResult<(StatusCode, Json<BulkCorrespondentResponse>)> {
|
||||
if payload.document_ids.is_empty() {
|
||||
return Err(AppError::bad_request("document_ids must not be empty"));
|
||||
}
|
||||
if payload.assignments.is_empty() {
|
||||
return Err(AppError::bad_request("assignments must not be empty"));
|
||||
}
|
||||
|
||||
let mut document_ids = payload.document_ids;
|
||||
document_ids.sort();
|
||||
document_ids.dedup();
|
||||
validate_bulk_ids(&mut document_ids, "document_ids")?;
|
||||
|
||||
let (normalized_pairs, correspondents_vec, roles_vec) =
|
||||
normalize_correspondent_assignments(&payload.assignments)?;
|
||||
@@ -1503,10 +1483,8 @@ pub async fn bulk_assign_correspondents(
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(BulkCorrespondentResponse { assigned, removed }),
|
||||
))
|
||||
let body = BulkCorrespondentResponse { assigned, removed };
|
||||
Ok((StatusCode::OK, body.into_json()?))
|
||||
}
|
||||
|
||||
pub async fn remove_correspondent(
|
||||
@@ -1517,7 +1495,7 @@ pub async fn remove_correspondent(
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
) -> AppResult<StatusCode> {
|
||||
let role = normalize_role(&query.role);
|
||||
if role.is_empty() {
|
||||
return Err(AppError::bad_request("role must not be empty"));
|
||||
@@ -1558,7 +1536,7 @@ pub async fn remove_correspondent(
|
||||
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
no_content()
|
||||
}
|
||||
|
||||
pub async fn assign_tags(
|
||||
@@ -1624,17 +1602,8 @@ pub async fn bulk_update_tags(
|
||||
action,
|
||||
} = payload;
|
||||
|
||||
if document_ids.is_empty() {
|
||||
return Err(AppError::bad_request("document_ids must not be empty"));
|
||||
}
|
||||
if tag_ids.is_empty() {
|
||||
return Err(AppError::bad_request("tag_ids must not be empty"));
|
||||
}
|
||||
|
||||
document_ids.sort();
|
||||
document_ids.dedup();
|
||||
tag_ids.sort();
|
||||
tag_ids.dedup();
|
||||
validate_bulk_ids(&mut document_ids, "document_ids")?;
|
||||
validate_bulk_ids(&mut tag_ids, "tag_ids")?;
|
||||
|
||||
let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
|
||||
.filter(documents::id.eq_any(&document_ids))
|
||||
@@ -1700,7 +1669,7 @@ pub async fn bulk_update_tags(
|
||||
}
|
||||
};
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
Ok((StatusCode::OK, response.into_json()?))
|
||||
}
|
||||
|
||||
pub async fn remove_tag(
|
||||
@@ -1710,7 +1679,7 @@ pub async fn remove_tag(
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
) -> AppResult<StatusCode> {
|
||||
diesel::delete(
|
||||
document_tags::table
|
||||
.filter(document_tags::document_id.eq(document_id))
|
||||
@@ -1719,7 +1688,7 @@ pub async fn remove_tag(
|
||||
)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
no_content()
|
||||
}
|
||||
|
||||
async fn process_upload(
|
||||
@@ -1810,8 +1779,9 @@ async fn process_upload(
|
||||
|
||||
let content_disposition = inline_content_disposition(&original_name);
|
||||
|
||||
state
|
||||
.storage
|
||||
let storage = state.storage_for_tenant(tenant_id)?;
|
||||
|
||||
storage
|
||||
.put_object(
|
||||
&s3_key,
|
||||
bytes.clone(),
|
||||
@@ -1915,10 +1885,7 @@ fn ensure_folder_exists(state: &AppState, tenant_id: Uuid, folder_id: Uuid) -> A
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
))
|
||||
.get_result(&mut conn)?;
|
||||
if !exists {
|
||||
return Err(AppError::bad_request("folder does not exist"));
|
||||
}
|
||||
Ok(())
|
||||
ensure_exists(exists, "folder")
|
||||
}
|
||||
|
||||
pub(crate) fn load_tags_for_documents(
|
||||
@@ -2218,15 +2185,17 @@ async fn load_asset_responses(
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn to_iso(dt: NaiveDateTime) -> String {
|
||||
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).to_rfc3339()
|
||||
}
|
||||
|
||||
async fn quickwit_search(endpoint: &str, index: &str, query: &str) -> anyhow::Result<Vec<Uuid>> {
|
||||
async fn quickwit_search(
|
||||
endpoint: &str,
|
||||
index: &str,
|
||||
tenant_id: Uuid,
|
||||
query: &str,
|
||||
) -> anyhow::Result<Vec<Uuid>> {
|
||||
let tenant_clause = format!("tenant_id:{}", tenant_id);
|
||||
let quickwit_query = match build_quickwit_query(query) {
|
||||
Some(q) => {
|
||||
debug!(%query, quickwit_query = %q, "built quickwit search query");
|
||||
q
|
||||
format!("{} AND ({})", tenant_clause, q)
|
||||
}
|
||||
None => {
|
||||
debug!(%query, "quickwit search skipped because query produced no tokens");
|
||||
|
||||
Reference in New Issue
Block a user