multi tenancy part 1

This commit is contained in:
2025-10-22 22:52:59 +02:00
parent 8e3f09774a
commit e175d28c2c
35 changed files with 1288 additions and 314 deletions
+152 -72
View File
@@ -1,11 +1,15 @@
use axum::{
extract::State,
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json,
};
use axum_extra::{headers::Cookie, typed_header::TypedHeader};
use axum_extra::{
headers::{authorization::Bearer, Authorization, Cookie},
typed_header::TypedHeader,
};
use chrono::{Duration as ChronoDuration, Utc};
use diesel::prelude::*;
use diesel::{pg::PgConnection, prelude::*};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -14,8 +18,11 @@ use uuid::Uuid;
use crate::{
auth::{password, AuthenticatedUser},
error::{AppError, AppResult},
models::{NewRefreshToken, RefreshToken, User},
schema::{refresh_tokens, users::dsl},
models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership},
schema::{
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
users::dsl,
},
state::AppState,
};
@@ -27,6 +34,8 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token";
pub struct LoginRequest {
pub username: String,
pub password: String,
#[serde(default)]
pub preferred_tenant_slug: Option<String>,
}
#[derive(Serialize)]
@@ -36,11 +45,28 @@ pub struct LoginResponse {
pub expires_in: i64,
}
#[derive(Serialize)]
pub struct TenantSummary {
pub tenant_id: Uuid,
pub slug: String,
}
#[derive(Serialize)]
pub struct TenantSelectionResponse {
pub selection_token: String,
pub tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
}
pub async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
let mut conn = state.db()?;
) -> AppResult<Response> {
let mut conn = state.db_unscoped()?;
let user: User = dsl::users
.filter(dsl::username.eq(&payload.username))
@@ -53,55 +79,67 @@ pub async fn login(
return Err(AppError::unauthorized());
}
let access_token = state
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user.id))
.load(&mut conn)?;
if memberships.is_empty() {
return Err(AppError::unauthorized());
}
let preferred_slug = payload
.preferred_tenant_slug
.as_ref()
.map(|slug| slug.trim().to_string())
.filter(|slug| !slug.is_empty());
if let Some(tenant) = preferred_slug.as_ref().and_then(|slug| {
memberships
.iter()
.find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug))
}) {
return issue_session(&state, &mut conn, &user, tenant.1.tenant_id);
}
if memberships.len() == 1 {
let tenant_id = memberships[0].1.tenant_id;
return issue_session(&state, &mut conn, &user, tenant_id);
}
let selection_token = state
.jwt
.generate_token(user.id, &user.username, &user.role)
.generate_tenant_selector_token(user.id)
.map_err(AppError::from)?;
let now = Utc::now();
let refresh_value = generate_refresh_token();
let refresh_hash = hash_refresh_token(&refresh_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let tenants = memberships
.into_iter()
.map(|(_, tenant)| TenantSummary {
tenant_id: tenant.tenant_id,
slug: tenant.slug,
})
.collect();
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: refresh_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
};
let response = Json(TenantSelectionResponse {
selection_token,
tenants,
})
.into_response();
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &refresh_value, refresh_expires_at),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
Ok(response)
}
pub async fn refresh(
State(state): State<AppState>,
jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
) -> AppResult<Response> {
let cookies = jar.ok_or_else(AppError::unauthorized)?;
let refresh_value = cookies
.get(REFRESH_COOKIE_NAME)
.ok_or_else(AppError::unauthorized)?;
let hashed = hash_refresh_token(refresh_value);
let mut conn = state.db()?;
let mut conn = state.db_unscoped()?;
let now = Utc::now();
let now_naive = now.naive_utc();
@@ -128,41 +166,39 @@ pub async fn refresh(
.first(&mut conn)
.map_err(AppError::from)?;
let access_token = state
issue_session(&state, &mut conn, &user, token.tenant_id)
}
pub async fn select_tenant(
State(state): State<AppState>,
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
Json(payload): Json<TenantSelectionRequest>,
) -> AppResult<Response> {
let claims = state
.jwt
.generate_token(user.id, &user.username, &user.role)
.verify_tenant_selector_token(bearer.token())
.map_err(|_| AppError::unauthorized())?;
let mut conn = state.db_unscoped()?;
let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(claims.sub))
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
.inner_join(tenant_dsl::tenants)
.select(memberships_dsl::id)
.first::<Uuid>(&mut conn)
.optional()?;
if membership_exists.is_none() {
return Err(AppError::unauthorized());
}
let user: User = dsl::users
.find(claims.sub)
.first(&mut conn)
.map_err(AppError::from)?;
let new_refresh_value = generate_refresh_token();
let new_refresh_hash = hash_refresh_token(&new_refresh_value);
let new_refresh_expires = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: new_refresh_hash,
issued_at: now_naive,
expires_at: new_refresh_expires.naive_utc(),
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &new_refresh_value, new_refresh_expires),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
issue_session(&state, &mut conn, &user, payload.tenant_id)
}
pub async fn logout(
@@ -170,7 +206,7 @@ pub async fn logout(
user: AuthenticatedUser,
jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> {
let mut conn = state.db()?;
let mut conn = state.db_unscoped()?;
let now = Utc::now().naive_utc();
let mut rows_affected = 0;
@@ -214,6 +250,50 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user)
}
fn issue_session(
state: &AppState,
conn: &mut PgConnection,
user: &User,
tenant_id: Uuid,
) -> AppResult<Response> {
let now = Utc::now();
let access_token = state
.jwt
.generate_token(user.id, tenant_id, &user.username)
.map_err(AppError::from)?;
let refresh_value = generate_refresh_token();
let refresh_hash = hash_refresh_token(&refresh_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: refresh_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id,
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(conn)?;
let mut response = Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
})
.into_response();
response.headers_mut().insert(
SET_COOKIE,
build_refresh_cookie(state, &refresh_value, refresh_expires_at),
);
Ok(response)
}
fn hash_refresh_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
+46 -24
View File
@@ -1,11 +1,6 @@
use std::collections::{BTreeMap, HashMap};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json};
use chrono::Utc;
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
use serde::{Deserialize, Serialize};
@@ -13,10 +8,10 @@ use serde_json::Value;
use uuid::Uuid;
use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult},
models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents},
state::AppState,
};
use super::documents::to_iso;
@@ -59,15 +54,19 @@ struct CorrespondentChangeset<'a> {
}
pub async fn list_correspondents(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
let mut conn = state.db()?;
let correspondents_list: Vec<Correspondent> = correspondents::table
.filter(correspondents::tenant_id.eq(tenant_id))
.order(correspondents::name.asc())
.load(&mut conn)?;
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by((
document_correspondents::correspondent_id,
document_correspondents::role,
@@ -97,7 +96,11 @@ pub async fn list_correspondents(
}
pub async fn create_correspondent(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> {
let name = payload.name.trim();
@@ -111,9 +114,9 @@ pub async fn create_correspondent(
id: new_id,
name: name.to_string(),
metadata: metadata_value,
tenant_id,
};
let mut conn = state.db()?;
match diesel::insert_into(correspondents::table)
.values(&new_correspondent)
.execute(&mut conn)
@@ -130,13 +133,17 @@ pub async fn create_correspondent(
}
pub async fn update_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> {
let mut conn = state.db()?;
let existing: Correspondent = correspondents::table
.find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
let mut new_name: Option<String> = None;
@@ -149,6 +156,7 @@ pub async fn update_correspondent(
let duplicate = correspondents::table
.filter(correspondents::name.eq(trimmed))
.filter(correspondents::id.ne(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id))
.first::<Correspondent>(&mut conn)
.optional()?;
if duplicate.is_some() {
@@ -167,7 +175,7 @@ pub async fn update_correspondent(
}
if new_name.is_none() && new_metadata.is_none() {
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
return Ok(Json(build_summary(existing.clone(), usage)));
}
@@ -180,24 +188,32 @@ pub async fn update_correspondent(
}
let now = Utc::now().naive_utc();
diesel::update(correspondents::table.find(correspondent_id))
.set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?;
diesel::update(
correspondents::table
.find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?;
let updated: Correspondent = correspondents::table
.find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
Ok(Json(build_summary(updated, usage)))
}
pub async fn delete_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
let usage: i64 = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.select(count_star())
.first(&mut conn)?;
@@ -208,8 +224,12 @@ pub async fn delete_correspondent(
));
}
let deleted =
diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?;
let deleted = diesel::delete(
correspondents::table
.filter(correspondents::id.eq(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
@@ -243,10 +263,12 @@ fn normalize_metadata(input: Option<Value>) -> Value {
fn load_usage_for_correspondent(
conn: &mut PgConnection,
tenant_id: Uuid,
correspondent_id: Uuid,
) -> AppResult<BTreeMap<String, i64>> {
let rows: Vec<(String, i64)> = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(document_correspondents::role)
.select((document_correspondents::role, count_star()))
.load(conn)?;
+273 -121
View File
@@ -18,7 +18,7 @@ use tracing::{debug, error, info, warn};
use uuid::Uuid;
use super::folders::gather_descendant_folder_ids;
use crate::auth::AuthenticatedUser;
use crate::auth::TenantScopedConn;
use crate::error::{AppError, AppResult};
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
use crate::models::{
@@ -372,10 +372,13 @@ pub struct AssetObjectsQuery {
pub async fn list_documents(
State(state): State<AppState>,
Query(params): Query<DocumentListQuery>,
user: AuthenticatedUser,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentResponse>>> {
let mut conn = state.db()?;
let DocumentListQuery {
folder_id,
include_deleted,
@@ -385,7 +388,9 @@ pub async fn list_documents(
correspondents,
} = params;
let mut docs_query = documents::table.into_boxed();
let mut docs_query = documents::table
.filter(documents::tenant_id.eq(tenant_id))
.into_boxed();
if !include_deleted {
docs_query = docs_query.filter(documents::deleted_at.is_null());
@@ -414,7 +419,7 @@ pub async fn list_documents(
match (folder_id, include_descendants) {
(Some(folder_id), true) => {
let descendant_ids = gather_descendant_folder_ids(&mut conn, folder_id)?;
let descendant_ids = gather_descendant_folder_ids(&mut conn, tenant_id, folder_id)?;
docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids));
}
(Some(folder_id), false) => {
@@ -598,7 +603,7 @@ pub async fn list_documents(
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let primary_versions = load_primary_assets(&state, &docs).await?;
let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?;
let mut response = Vec::with_capacity(doc_ids.len());
for doc in docs {
let tags = tags_map.get(&doc.id).cloned();
@@ -606,7 +611,7 @@ pub async fn list_documents(
let current_version = primary_versions.get(&doc.id).cloned();
response.push(to_document_response(
&state,
user.user_id,
user_id,
doc,
tags,
correspondents,
@@ -620,11 +625,17 @@ pub async fn list_documents(
pub async fn get_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
user: AuthenticatedUser,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<DocumentDetailResponse>> {
let mut conn = state.db()?;
let doc: Document = documents::table.find(document_id).first(&mut conn)?;
let doc: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
@@ -638,13 +649,13 @@ pub async fn get_document(
let version_id = current_version.id;
drop(conn);
let assets = load_asset_responses(&state, version_id).await?;
let assets = load_asset_responses(&state, tenant_id, version_id).await?;
let version_response = to_version_response(current_version, true);
Ok(Json(DocumentDetailResponse {
document: to_document_response(
&state,
user.user_id,
user_id,
doc,
tags_map.get(&document_id).cloned(),
correspondents_map.remove(&document_id).unwrap_or_default(),
@@ -655,7 +666,9 @@ pub async fn get_document(
pub async fn upload_document(
State(state): State<AppState>,
user: AuthenticatedUser,
TenantScopedConn {
tenant_id, user_id, ..
}: TenantScopedConn,
mut multipart: Multipart,
) -> AppResult<(StatusCode, Json<DocumentDetailResponse>)> {
let mut file_bytes: Option<Vec<u8>> = None;
@@ -733,7 +746,7 @@ pub async fn upload_document(
metadata,
};
let outcome = match process_upload(&state, request, user.user_id).await {
let outcome = match process_upload(&state, request, tenant_id, user_id).await {
Ok(outcome) => {
info!(
document_id = %outcome.detail.document.id,
@@ -759,18 +772,25 @@ pub async fn upload_document(
}
pub async fn request_document_assets(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
Query(query): Query<AssetRequestQuery>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let mut conn = state.db()?;
let document: Document = documents::table.find(document_id).first(&mut conn)?;
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if document.deleted_at.is_some() {
return Err(AppError::not_found());
}
enqueue_job(
&mut conn,
tenant_id,
JOB_ANALYZE_DOCUMENT,
json!({
"document_id": document_id,
@@ -785,13 +805,15 @@ pub async fn request_document_assets(
}
pub async fn reanalyze_all_documents(
State(state): State<AppState>,
_user: AuthenticatedUser,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
let mut conn = state.db()?;
let targets: Vec<(Uuid, Uuid)> = documents::table
.filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::current_version_id))
.load(&mut conn)?;
@@ -799,6 +821,7 @@ pub async fn reanalyze_all_documents(
for (document_id, version_id) in targets {
enqueue_job(
&mut conn,
tenant_id,
JOB_ANALYZE_DOCUMENT,
json!({
"document_id": document_id,
@@ -815,7 +838,11 @@ pub async fn reanalyze_all_documents(
}
pub async fn reanalyze_selected_documents(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkReanalyzeSelectionRequest>,
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
let BulkReanalyzeSelectionRequest {
@@ -830,11 +857,10 @@ pub async fn reanalyze_selected_documents(
document_ids.sort();
document_ids.dedup();
let mut conn = state.db()?;
let targets: Vec<(Uuid, Uuid)> = documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::current_version_id))
.load(&mut conn)?;
@@ -848,6 +874,7 @@ pub async fn reanalyze_selected_documents(
for (document_id, version_id) in targets {
enqueue_job(
&mut conn,
tenant_id,
JOB_ANALYZE_DOCUMENT,
json!({
"document_id": document_id,
@@ -866,9 +893,16 @@ pub async fn reanalyze_selected_documents(
pub async fn list_document_assets(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentAssetResponse>>> {
let mut conn = state.db()?;
let document: Document = documents::table.find(document_id).first(&mut conn)?;
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if document.deleted_at.is_some() {
return Err(AppError::not_found());
}
@@ -876,7 +910,7 @@ pub async fn list_document_assets(
let version_id = document.current_version_id;
drop(conn);
let assets = load_asset_responses(&state, version_id).await?;
let assets = load_asset_responses(&state, tenant_id, version_id).await?;
Ok(Json(assets))
}
@@ -884,11 +918,15 @@ pub async fn get_document_asset(
State(state): State<AppState>,
Path(asset_id): Path<Uuid>,
Query(query): Query<AssetObjectsQuery>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<DocumentAssetDetailResponse>> {
let mut conn = state.db()?;
let asset: DocumentAsset = match document_assets::table
.find(asset_id)
.filter(document_assets::tenant_id.eq(tenant_id))
.first(&mut conn)
.optional()?
{
@@ -911,6 +949,7 @@ pub async fn get_document_asset(
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset_id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.filter(document_asset_objects::ordinal.ge(start))
.filter(document_asset_objects::ordinal.le(end))
.order(document_asset_objects::ordinal.asc())
@@ -951,9 +990,16 @@ pub async fn get_document_asset(
pub async fn download_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<DocumentDownloadResponse>> {
let mut conn = state.db()?;
let doc: Document = documents::table.find(document_id).first(&mut conn)?;
let doc: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
@@ -989,9 +1035,12 @@ pub async fn download_with_token(
.verify_download_token(&token)
.map_err(|_| AppError::unauthorized())?;
let mut conn = state.db()?;
let mut conn = state.db_for_tenant(claims.tenant_id)?;
let doc: Document = documents::table.find(claims.doc_id).first(&mut conn)?;
let doc: Document = documents::table
.find(claims.doc_id)
.filter(documents::tenant_id.eq(claims.tenant_id))
.first(&mut conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
@@ -1004,6 +1053,7 @@ pub async fn download_with_token(
let has_active_refresh: bool = select(exists(
refresh_dsl::refresh_tokens
.filter(refresh_dsl::user_id.eq(claims.user_id))
.filter(refresh_dsl::tenant_id.eq(claims.tenant_id))
.filter(refresh_dsl::revoked_at.is_null())
.filter(refresh_dsl::expires_at.gt(now)),
))
@@ -1028,29 +1078,42 @@ pub async fn download_with_token(
}
pub async fn delete_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document_id))
.set((
documents::deleted_at.eq(Some(now)),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set((
documents::deleted_at.eq(Some(now)),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn update_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
user: AuthenticatedUser,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateDocumentRequest>,
) -> AppResult<Json<DocumentDetailResponse>> {
let mut conn = state.db()?;
let mut document: Document = documents::table.find(document_id).first(&mut conn)?;
let mut document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if document.deleted_at.is_some() {
return Err(AppError::not_found());
}
@@ -1074,7 +1137,11 @@ pub async fn update_document(
let now = Utc::now().naive_utc();
let new_filename = filename_with_retained_extension(&title, &document.filename);
let update_result = diesel::update(documents::table.find(document_id)).set((
let target = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id));
let update_result = diesel::update(target).set((
documents::title.eq(&title),
documents::filename.eq(&new_filename),
documents::updated_at.eq(now),
@@ -1090,7 +1157,10 @@ pub async fn update_document(
Err(err) => return Err(AppError::from(err)),
}
document = documents::table.find(document_id).first(&mut conn)?;
document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
}
let current_version: DocumentVersion = document_versions::table
@@ -1102,13 +1172,13 @@ pub async fn update_document(
let version_id = current_version.id;
drop(conn);
let assets = load_asset_responses(&state, version_id).await?;
let assets = load_asset_responses(&state, tenant_id, version_id).await?;
let version_response = to_version_response(current_version, true);
Ok(Json(DocumentDetailResponse {
document: to_document_response(
&state,
user.user_id,
user_id,
document,
tags_map.get(&document_id).cloned(),
correspondents_map.remove(&document_id).unwrap_or_default(),
@@ -1120,26 +1190,39 @@ pub async fn update_document(
pub async fn move_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<MoveDocumentRequest>,
) -> AppResult<impl IntoResponse> {
if let Some(folder_id) = payload.folder_id {
ensure_folder_exists(&state, folder_id)?;
ensure_folder_exists(&state, tenant_id, folder_id)?;
}
let mut conn = state.db()?;
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document_id))
.set((
documents::folder_id.eq(payload.folder_id),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set((
documents::folder_id.eq(payload.folder_id),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn bulk_move_documents(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkMoveRequest>,
) -> AppResult<(StatusCode, Json<BulkMoveResponse>)> {
let BulkMoveRequest {
@@ -1155,13 +1238,12 @@ pub async fn bulk_move_documents(
document_ids.dedup();
if let Some(target_folder) = folder_id {
ensure_folder_exists(&state, target_folder)?;
ensure_folder_exists(&state, tenant_id, target_folder)?;
}
let mut conn = state.db()?;
let existing: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::deleted_at))
.load(&mut conn)?;
@@ -1176,20 +1258,28 @@ pub async fn bulk_move_documents(
}
let now = Utc::now().naive_utc();
let updated = diesel::update(documents::table.filter(documents::id.eq_any(&document_ids)))
.set((
documents::folder_id.eq(folder_id),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
let updated = diesel::update(
documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id)),
)
.set((
documents::folder_id.eq(folder_id),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
Ok((StatusCode::OK, Json(BulkMoveResponse { updated })))
}
pub async fn assign_correspondents(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
user: AuthenticatedUser,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<AssignCorrespondentsRequest>,
) -> AppResult<impl IntoResponse> {
if payload.assignments.is_empty() {
@@ -1199,11 +1289,13 @@ pub async fn assign_correspondents(
let (normalized_pairs, correspondents_vec, roles_vec) =
normalize_correspondent_assignments(&payload.assignments)?;
let replace = payload.replace;
let user_id = user.user_id;
let user_id_val = user_id;
let mut conn = state.db()?;
conn.transaction::<(), AppError, _>(|conn| {
let document: Document = documents::table.find(document_id).first(conn)?;
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(conn)?;
if document.deleted_at.is_some() {
return Err(AppError::not_found());
}
@@ -1211,6 +1303,7 @@ pub async fn assign_correspondents(
if !correspondents_vec.is_empty() {
let existing: Vec<Correspondent> = correspondents::table
.filter(correspondents::id.eq_any(&correspondents_vec))
.filter(correspondents::tenant_id.eq(tenant_id))
.load(conn)?;
if existing.len() != correspondents_vec.len() {
return Err(AppError::bad_request(
@@ -1224,6 +1317,7 @@ pub async fn assign_correspondents(
let deleted = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::role.eq_any(&roles_vec)),
)
.execute(conn)?;
@@ -1238,7 +1332,8 @@ pub async fn assign_correspondents(
document_id,
correspondent_id: *correspondent_id,
role: role.clone(),
assigned_by: Some(user_id),
assigned_by: Some(user_id_val),
tenant_id,
})
.collect();
@@ -1253,9 +1348,13 @@ pub async fn assign_correspondents(
}
if changed {
diesel::update(documents::table.find(document_id))
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok(())
@@ -1265,8 +1364,12 @@ pub async fn assign_correspondents(
}
pub async fn bulk_assign_correspondents(
State(state): State<AppState>,
user: AuthenticatedUser,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkCorrespondentsRequest>,
) -> AppResult<(StatusCode, Json<BulkCorrespondentResponse>)> {
if payload.document_ids.is_empty() {
@@ -1283,12 +1386,11 @@ pub async fn bulk_assign_correspondents(
let (normalized_pairs, correspondents_vec, roles_vec) =
normalize_correspondent_assignments(&payload.assignments)?;
let action = payload.action;
let user_id = user.user_id;
let mut conn = state.db()?;
let user_id_val = user_id;
let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| {
let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::deleted_at))
.load(conn)?;
@@ -1307,6 +1409,7 @@ pub async fn bulk_assign_correspondents(
if !correspondents_vec.is_empty() {
let existing: Vec<Correspondent> = correspondents::table
.filter(correspondents::id.eq_any(&correspondents_vec))
.filter(correspondents::tenant_id.eq(tenant_id))
.load(conn)?;
if existing.len() != correspondents_vec.len() {
return Err(AppError::bad_request(
@@ -1322,6 +1425,7 @@ pub async fn bulk_assign_correspondents(
removed = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq_any(&document_ids))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::role.eq_any(&roles_vec)),
)
.execute(conn)?;
@@ -1334,7 +1438,8 @@ pub async fn bulk_assign_correspondents(
document_id: *doc_id,
correspondent_id: *correspondent_id,
role: role.clone(),
assigned_by: Some(user_id),
assigned_by: Some(user_id_val),
tenant_id,
});
}
}
@@ -1349,9 +1454,13 @@ pub async fn bulk_assign_correspondents(
};
if assigned > 0 || removed > 0 {
diesel::update(documents::table.filter(documents::id.eq_any(&document_ids)))
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
diesel::update(
documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok((assigned, removed))
@@ -1371,6 +1480,7 @@ pub async fn bulk_assign_correspondents(
removed += diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq_any(&document_ids))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::role.eq(role.as_str()))
.filter(document_correspondents::correspondent_id.eq_any(&ids)),
)
@@ -1379,9 +1489,13 @@ pub async fn bulk_assign_correspondents(
}
if removed > 0 {
diesel::update(documents::table.filter(documents::id.eq_any(&document_ids)))
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
diesel::update(
documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok((0, removed))
@@ -1396,9 +1510,13 @@ pub async fn bulk_assign_correspondents(
}
pub async fn remove_correspondent(
State(state): State<AppState>,
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
Query(query): Query<CorrespondentRoleQuery>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> {
let role = normalize_role(&query.role);
if role.is_empty() {
@@ -1411,8 +1529,10 @@ pub async fn remove_correspondent(
)));
}
let mut conn = state.db()?;
let document: Document = documents::table.find(document_id).first(&mut conn)?;
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(&mut conn)?;
if document.deleted_at.is_some() {
return Err(AppError::not_found());
}
@@ -1420,6 +1540,7 @@ pub async fn remove_correspondent(
let deleted = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::role.eq(&role)),
)
@@ -1429,33 +1550,41 @@ pub async fn remove_correspondent(
return Err(AppError::not_found());
}
diesel::update(documents::table.find(document_id))
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(&mut conn)?;
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn assign_tags(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
user: AuthenticatedUser,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<AssignTagsRequest>,
) -> AppResult<impl IntoResponse> {
if payload.tag_ids.is_empty() {
return Err(AppError::bad_request("tag_ids must not be empty"));
}
let mut conn = state.db()?;
// Ensure document exists
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first::<Document>(&mut conn)?;
// Ensure tags exist
let existing_tags: Vec<Tag> = tags::table
.filter(tags::id.eq_any(&payload.tag_ids))
.filter(tags::tenant_id.eq(tenant_id))
.load(&mut conn)?;
if existing_tags.len() != payload.tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist"));
@@ -1467,7 +1596,8 @@ pub async fn assign_tags(
.map(|tag_id| NewDocumentTag {
document_id,
tag_id: *tag_id,
assigned_by: Some(user.user_id),
assigned_by: Some(user_id),
tenant_id,
})
.collect();
@@ -1480,8 +1610,12 @@ pub async fn assign_tags(
}
pub async fn bulk_update_tags(
State(state): State<AppState>,
user: AuthenticatedUser,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkTagRequest>,
) -> AppResult<(StatusCode, Json<BulkTagResponse>)> {
let BulkTagRequest {
@@ -1502,10 +1636,9 @@ pub async fn bulk_update_tags(
tag_ids.sort();
tag_ids.dedup();
let mut conn = state.db()?;
let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::deleted_at))
.load(&mut conn)?;
@@ -1523,6 +1656,7 @@ pub async fn bulk_update_tags(
let existing_tags: Vec<Tag> = tags::table
.filter(tags::id.eq_any(&tag_ids))
.filter(tags::tenant_id.eq(tenant_id))
.load(&mut conn)?;
if existing_tags.len() != tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist"));
@@ -1536,7 +1670,8 @@ pub async fn bulk_update_tags(
inserts.push(NewDocumentTag {
document_id: *doc_id,
tag_id: *tag_id,
assigned_by: Some(user.user_id),
assigned_by: Some(user_id),
tenant_id,
});
}
}
@@ -1556,6 +1691,7 @@ pub async fn bulk_update_tags(
let removed = diesel::delete(
document_tags::table
.filter(document_tags::document_id.eq_any(&document_ids))
.filter(document_tags::tenant_id.eq(tenant_id))
.filter(document_tags::tag_id.eq_any(&tag_ids)),
)
.execute(&mut conn)?;
@@ -1568,13 +1704,17 @@ pub async fn bulk_update_tags(
}
pub async fn remove_tag(
State(state): State<AppState>,
Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
diesel::delete(
document_tags::table
.filter(document_tags::document_id.eq(document_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.filter(document_tags::tag_id.eq(tag_id)),
)
.execute(&mut conn)?;
@@ -1585,6 +1725,7 @@ pub async fn remove_tag(
async fn process_upload(
state: &AppState,
request: UploadRequest,
tenant_id: Uuid,
user_id: Uuid,
) -> AppResult<UploadOutcome> {
let UploadRequest {
@@ -1596,7 +1737,7 @@ async fn process_upload(
} = request;
if let Some(folder) = folder_id {
ensure_folder_exists(state, folder)?;
ensure_folder_exists(state, tenant_id, folder)?;
}
let doc_id = Uuid::new_v4();
@@ -1610,13 +1751,14 @@ async fn process_upload(
let s3_key = document_version_object_key(doc_id, version_number, version_id);
{
let mut conn = state.db()?;
let mut conn = state.db_for_tenant(tenant_id)?;
let existing = documents::table
.inner_join(
document_versions::table
.on(document_versions::id.eq(documents::current_version_id)),
)
.filter(documents::tenant_id.eq(tenant_id))
.filter(document_versions::checksum.eq(&checksum_hex))
.select((documents::all_columns, document_versions::all_columns))
.first::<(Document, DocumentVersion)>(&mut conn)
@@ -1641,7 +1783,7 @@ async fn process_upload(
let tags = tags_map.get(&document.id).cloned();
let correspondents = correspondents_map.remove(&document.id).unwrap_or_default();
drop(conn);
let assets = load_asset_responses(state, version.id).await?;
let assets = load_asset_responses(state, tenant_id, version.id).await?;
let version_response = to_version_response(version.clone(), true);
info!(
@@ -1689,7 +1831,7 @@ async fn process_upload(
};
let (document, version) = {
let mut conn = state.db()?;
let mut conn = state.db_for_tenant(tenant_id)?;
conn.transaction(|conn| {
let new_document = NewDocument {
id: doc_id,
@@ -1701,6 +1843,7 @@ async fn process_upload(
issued_at: None,
title: derive_document_title(&original_name),
metadata: metadata_value.clone(),
tenant_id,
};
diesel::insert_into(documents::table)
.values(&new_document)
@@ -1715,6 +1858,7 @@ async fn process_upload(
checksum: checksum_hex.clone(),
metadata: Value::Object(Default::default()),
operations_summary: Value::Object(Default::default()),
tenant_id,
};
diesel::insert_into(document_versions::table)
@@ -1739,9 +1883,10 @@ async fn process_upload(
)?,
};
if let Ok(mut conn) = state.db() {
if let Ok(mut conn) = state.db_for_tenant(tenant_id) {
if let Err(err) = enqueue_job(
&mut conn,
tenant_id,
JOB_ANALYZE_DOCUMENT,
json!({
"document_id": doc_id,
@@ -1762,10 +1907,14 @@ async fn process_upload(
})
}
fn ensure_folder_exists(state: &AppState, folder_id: Uuid) -> AppResult<()> {
let mut conn = state.db()?;
let exists: bool = diesel::select(exists(folders::table.filter(folders::id.eq(folder_id))))
.get_result(&mut conn)?;
fn ensure_folder_exists(state: &AppState, tenant_id: Uuid, folder_id: Uuid) -> AppResult<()> {
let mut conn = state.db_for_tenant(tenant_id)?;
let exists: bool = diesel::select(exists(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
))
.get_result(&mut conn)?;
if !exists {
return Err(AppError::bad_request("folder does not exist"));
}
@@ -1829,6 +1978,7 @@ pub(crate) fn load_correspondents_for_documents(
pub(crate) async fn load_primary_assets(
state: &AppState,
tenant_id: Uuid,
documents: &[Document],
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
if documents.is_empty() {
@@ -1845,7 +1995,7 @@ pub(crate) async fn load_primary_assets(
version_ids.sort();
version_ids.dedup();
let mut conn = state.db()?;
let mut conn = state.db_for_tenant(tenant_id)?;
let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::id.eq_any(&version_ids))
.load(&mut conn)?;
@@ -1905,7 +2055,7 @@ pub(crate) fn to_document_response(
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
) -> AppResult<DocumentResponse> {
let current_version = if let Some((version, assets)) = current_version {
let download_path = build_download_path(state, doc.id, user_id)?;
let download_path = build_download_path(state, &doc, user_id)?;
Some(DocumentCurrentVersionResponse {
version,
assets,
@@ -1937,10 +2087,10 @@ pub(crate) fn to_document_response(
})
}
fn build_download_path(state: &AppState, document_id: Uuid, user_id: Uuid) -> AppResult<String> {
fn build_download_path(state: &AppState, document: &Document, user_id: Uuid) -> AppResult<String> {
state
.jwt
.generate_download_token(document_id, user_id)
.generate_download_token(document.id, user_id, document.tenant_id)
.map(|token| format!("/download/{token}"))
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
}
@@ -2042,9 +2192,10 @@ fn filename_with_retained_extension(title: &str, current_filename: &str) -> Stri
async fn load_asset_responses(
state: &AppState,
tenant_id: Uuid,
version_id: Uuid,
) -> AppResult<Vec<DocumentAssetResponse>> {
let mut conn = state.db()?;
let mut conn = state.db_for_tenant(tenant_id)?;
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
.left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id
@@ -2052,6 +2203,7 @@ async fn load_asset_responses(
.and(document_asset_objects::ordinal.eq(1))),
)
.filter(document_assets::document_version_id.eq(version_id))
.filter(document_assets::tenant_id.eq(tenant_id))
.order(document_assets::created_at.asc())
.select((
document_assets::all_columns,
+77 -31
View File
@@ -10,7 +10,7 @@ use crate::models::{Document, Folder, NewFolder};
use crate::schema::{documents, folders};
use crate::state::AppState;
use crate::{
auth::AuthenticatedUser,
auth::TenantScopedConn,
error::{AppError, AppResult},
};
@@ -70,15 +70,17 @@ pub struct FolderInfo {
}
pub async fn ensure_folder_path(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<Json<FolderResponse>> {
if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be empty"));
}
let mut conn = state.db()?;
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = None;
@@ -93,12 +95,14 @@ pub async fn ensure_folder_path(
folders::table
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)
.optional()?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)
.optional()?
};
@@ -110,6 +114,7 @@ pub async fn ensure_folder_path(
id: Uuid::new_v4(),
name: name.to_string(),
parent_id: current_parent,
tenant_id,
};
diesel::insert_into(folders::table)
@@ -132,19 +137,22 @@ pub async fn ensure_folder_path(
}
pub async fn create_folder(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateFolderRequest>,
) -> AppResult<Json<FolderResponse>> {
if payload.name.trim().is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
let mut conn = state.db()?;
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: payload.name.trim().to_string(),
parent_id: payload.parent_id,
tenant_id,
};
diesel::insert_into(folders::table)
@@ -161,10 +169,13 @@ pub async fn list_folder_contents(
State(state): State<AppState>,
Path(folder_identifier): Path<String>,
Query(query): Query<FolderContentsQuery>,
user: AuthenticatedUser,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<FolderContentsResponse>> {
let mut conn = state.db()?;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None
} else {
@@ -176,7 +187,10 @@ pub async fn list_folder_contents(
let folder = match folder_id {
Some(id) => Some(folder_to_info(
folders::table.find(id).first::<Folder>(&mut conn)?,
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(&mut conn)?,
)),
None => None,
};
@@ -184,11 +198,13 @@ pub async fn list_folder_contents(
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
folders::table
.filter(folders::parent_id.eq(parent_id))
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc())
.load(&mut conn)?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc())
.load(&mut conn)?
};
@@ -197,6 +213,7 @@ pub async fn list_folder_contents(
let documents = if query.include_documents {
let docs_query = documents::table
.filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.order(documents::uploaded_at.desc());
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
@@ -214,7 +231,7 @@ pub async fn list_folder_contents(
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let primary_versions = load_primary_assets(&state, &docs).await?;
let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?;
let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs {
@@ -223,7 +240,7 @@ pub async fn list_folder_contents(
let current_version = primary_versions.get(&doc.id).cloned();
documents.push(to_document_response(
&state,
user.user_id,
user_id,
doc,
tags,
correspondents,
@@ -244,16 +261,23 @@ pub async fn list_folder_contents(
}
pub async fn delete_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let mut conn = state.db()?;
conn.transaction::<_, AppError, _>(|conn| {
folders::table.find(folder_id).first::<Folder>(conn)?;
folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)?;
let has_child_folders: bool = diesel::select(exists(
folders::table.filter(folders::parent_id.eq(Some(folder_id))),
folders::table
.filter(folders::parent_id.eq(Some(folder_id)))
.filter(folders::tenant_id.eq(tenant_id)),
))
.get_result(conn)?;
@@ -266,6 +290,7 @@ pub async fn delete_folder(
let has_documents: bool = diesel::select(exists(
documents::table
.filter(documents::folder_id.eq(Some(folder_id)))
.filter(documents::tenant_id.eq(tenant_id))
.filter(documents::deleted_at.is_null()),
))
.get_result(conn)?;
@@ -276,7 +301,12 @@ pub async fn delete_folder(
));
}
diesel::delete(folders::table.find(folder_id)).execute(conn)?;
diesel::delete(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
)
.execute(conn)?;
Ok(())
})?;
@@ -285,14 +315,19 @@ pub async fn delete_folder(
}
pub async fn update_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> {
let mut conn = state.db()?;
conn.transaction::<(), AppError, _>(|conn| {
let folder: Folder = folders::table.find(folder_id).first(conn)?;
let folder: Folder = folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let mut next_parent = folder.parent_id;
let mut parent_changed = false;
@@ -303,9 +338,12 @@ pub async fn update_folder(
}
if let Some(parent_id) = parent_request {
let _parent: Folder = folders::table.find(parent_id).first(conn)?;
let _parent: Folder = folders::table
.find(parent_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?;
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
if descendant_ids.contains(&parent_id) {
return Err(AppError::bad_request(
"cannot move folder into itself or a descendant",
@@ -341,6 +379,7 @@ pub async fn update_folder(
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)
.optional()?
} else {
@@ -348,6 +387,7 @@ pub async fn update_folder(
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)
.optional()?
};
@@ -358,12 +398,16 @@ pub async fn update_folder(
));
}
diesel::update(folders::table.find(folder_id))
.set((
folders::parent_id.eq(next_parent),
folders::name.eq(&new_name),
))
.execute(conn)?;
diesel::update(
folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id)),
)
.set((
folders::parent_id.eq(next_parent),
folders::name.eq(&new_name),
))
.execute(conn)?;
Ok(())
})?;
@@ -383,6 +427,7 @@ fn folder_to_info(folder: Folder) -> FolderInfo {
pub(super) fn gather_descendant_folder_ids(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id];
@@ -391,6 +436,7 @@ pub(super) fn gather_descendant_folder_ids(
while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = folders::table
.filter(folders::parent_id.eq(Some(current)))
.filter(folders::tenant_id.eq(tenant_id))
.select(folders::id)
.load(conn)?;
queue.extend(child_ids.iter().copied());
+1
View File
@@ -50,6 +50,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/login", post(auth::login))
.route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout))
.route("/select-tenant", post(auth::select_tenant))
.route("/me", get(auth::me));
let documents_routes = Router::new()
+55 -23
View File
@@ -1,19 +1,15 @@
use crate::utils::json::{classify_nullable, NullableValue};
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use axum::{extract::Path, http::StatusCode, Json};
use diesel::{dsl::count_star, prelude::*};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use uuid::Uuid;
use crate::auth::TenantScopedConn;
use crate::error::{AppError, AppResult};
use crate::models::{NewTag, Tag};
use crate::schema::{document_tags, tags};
use crate::state::AppState;
#[derive(Deserialize)]
pub struct CreateTagRequest {
@@ -36,12 +32,17 @@ pub struct TagCatalogEntry {
pub usage_count: i64,
}
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> {
let mut conn = state.db()?;
pub async fn list_tags(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<TagCatalogEntry>>> {
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
let usage_rows: Vec<(Uuid, i64)> = document_tags::table
.filter(document_tags::tenant_id.eq(tenant_id))
.group_by(document_tags::tag_id)
.select((document_tags::tag_id, count_star()))
.load(&mut conn)?;
@@ -62,18 +63,22 @@ pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagC
}
pub async fn create_tag(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> {
if payload.label.trim().is_empty() {
return Err(AppError::bad_request("label must not be empty"));
}
let mut conn = state.db()?;
let new_tag = NewTag {
id: Uuid::new_v4(),
label: payload.label.trim().to_string(),
color: payload.color,
tenant_id,
};
match diesel::insert_into(tags::table)
@@ -90,7 +95,10 @@ pub async fn create_tag(
Err(err) => return Err(AppError::from(err)),
}
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?;
let tag: Tag = tags::table
.find(new_tag.id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)?;
Ok(Json(TagCatalogEntry {
id: tag.id,
label: tag.label,
@@ -100,12 +108,18 @@ pub async fn create_tag(
}
pub async fn update_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(body): Json<Value>,
) -> AppResult<Json<TagCatalogEntry>> {
let mut conn = state.db()?;
let existing: Tag = tags::table.find(tag_id).first(&mut conn)?;
let existing: Tag = tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)?;
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?;
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
@@ -140,6 +154,7 @@ pub async fn update_tag(
let duplicate = tags::table
.filter(tags::label.eq(trimmed))
.filter(tags::id.ne(tag_id))
.filter(tags::tenant_id.eq(tenant_id))
.first::<Tag>(&mut conn)
.optional()?;
if duplicate.is_some() {
@@ -174,6 +189,7 @@ pub async fn update_tag(
if !label_changed && !color_changed {
let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star())
.first(&mut conn)?;
return Ok(Json(TagCatalogEntry {
@@ -191,13 +207,21 @@ pub async fn update_tag(
.map(|opt| opt.as_ref().map(|value| value.as_str())),
};
diesel::update(tags::table.find(tag_id))
.set(&changeset)
.execute(&mut conn)?;
diesel::update(
tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)),
)
.set(&changeset)
.execute(&mut conn)?;
let updated: Tag = tags::table.find(tag_id).first(&mut conn)?;
let updated: Tag = tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn)?;
let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star())
.first(&mut conn)?;
@@ -210,13 +234,16 @@ pub async fn update_tag(
}
pub async fn delete_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl axum::response::IntoResponse> {
let mut conn = state.db()?;
let usage: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star())
.first(&mut conn)?;
@@ -226,7 +253,12 @@ pub async fn delete_tag(
));
}
let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?;
let deleted = diesel::delete(
tags::table
.find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
+3 -3
View File
@@ -221,7 +221,7 @@ fn fetch_folder_contents(
state: &AppState,
folder_id: Option<Uuid>,
) -> AppResult<WebDavFolderContents> {
let mut conn = state.db()?;
let mut conn = state.db_unscoped()?;
let folder = match folder_id {
Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?),
@@ -405,7 +405,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
};
tracing::debug!(%username, "attempting webdav login");
let mut conn = state.db()?;
let mut conn = state.db_unscoped()?;
let user: User = match users_dsl::users
.filter(users_dsl::username.eq(username))
@@ -654,7 +654,7 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
return Ok(Some(ResolvedPath::Root));
}
let mut conn = state.db()?;
let mut conn = state.db_unscoped()?;
let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = Vec::new();
let mut current_folder: Option<Folder> = None;