diff --git a/backend/migrations/202512010160_replace_tenant_slug/down.sql b/backend/migrations/202512010160_replace_tenant_slug/down.sql new file mode 100644 index 0000000..18dde60 --- /dev/null +++ b/backend/migrations/202512010160_replace_tenant_slug/down.sql @@ -0,0 +1,5 @@ +ALTER TABLE tenants + RENAME COLUMN name TO slug; + +ALTER TABLE tenants + RENAME CONSTRAINT tenants_name_key TO tenants_slug_key; diff --git a/backend/migrations/202512010160_replace_tenant_slug/up.sql b/backend/migrations/202512010160_replace_tenant_slug/up.sql new file mode 100644 index 0000000..fba2d6b --- /dev/null +++ b/backend/migrations/202512010160_replace_tenant_slug/up.sql @@ -0,0 +1,5 @@ +ALTER TABLE tenants + RENAME COLUMN slug TO name; + +ALTER TABLE tenants + RENAME CONSTRAINT tenants_slug_key TO tenants_name_key; diff --git a/backend/src/bin/admin.rs b/backend/src/bin/admin.rs index 823333c..d66b533 100644 --- a/backend/src/bin/admin.rs +++ b/backend/src/bin/admin.rs @@ -39,23 +39,23 @@ enum Command { username: String, }, CreateTenant { - slug: String, + name: String, storage_root: Option, quickwit_index: Option, }, DeleteTenant { - slug: String, + name: String, }, AddUserToTenant { username: String, - slug: String, + name: String, }, RemoveUserFromTenant { username: String, - slug: String, + name: String, }, ReanalyzeDocuments { - slug: String, + name: String, }, ListTenants, DeleteAssets(String), @@ -70,15 +70,15 @@ impl Command { set-password \n\ list-users\n\ delete-user \n\ - create-tenant [storage_root] [quickwit_index]\n\ - delete-tenant \n\ - add-user-to-tenant \n\ - remove-user-from-tenant \n\ - reanalyze-documents \n\ + create-tenant [storage_root] [quickwit_index]\n\ + delete-tenant \n\ + add-user-to-tenant \n\ + remove-user-from-tenant \n\ + reanalyze-documents \n\ list-tenants\n\ - delete-assets \n\ - quickwit-create-index \n\ - quickwit-delete-index " + delete-assets \n\ + quickwit-create-index \n\ + quickwit-delete-index " } fn parse() -> Result { @@ -97,33 +97,33 @@ impl Command { username: args.next().ok_or_else(|| anyhow!("username required"))?, }), Some("create-tenant") => Ok(Self::CreateTenant { - slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?, + name: args.next().ok_or_else(|| anyhow!("tenant name required"))?, storage_root: args.next(), quickwit_index: args.next(), }), Some("delete-tenant") => Ok(Self::DeleteTenant { - slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?, + name: args.next().ok_or_else(|| anyhow!("tenant name required"))?, }), Some("add-user-to-tenant") => Ok(Self::AddUserToTenant { username: args.next().ok_or_else(|| anyhow!("username required"))?, - slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?, + name: args.next().ok_or_else(|| anyhow!("tenant name required"))?, }), Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant { username: args.next().ok_or_else(|| anyhow!("username required"))?, - slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?, + name: args.next().ok_or_else(|| anyhow!("tenant name required"))?, }), Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments { - slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?, + name: args.next().ok_or_else(|| anyhow!("tenant name required"))?, }), Some("list-tenants") => Ok(Self::ListTenants), Some("delete-assets") => Ok(Self::DeleteAssets( - args.next().ok_or_else(|| anyhow!("tenant slug required"))?, + args.next().ok_or_else(|| anyhow!("tenant name required"))?, )), Some("quickwit-create-index") => Ok(Self::QuickwitCreate( - args.next().ok_or_else(|| anyhow!("tenant slug required"))?, + args.next().ok_or_else(|| anyhow!("tenant name required"))?, )), Some("quickwit-delete-index") => Ok(Self::QuickwitDelete( - args.next().ok_or_else(|| anyhow!("tenant slug required"))?, + args.next().ok_or_else(|| anyhow!("tenant name required"))?, )), _ => Err(anyhow!(Self::usage())), } @@ -143,23 +143,23 @@ async fn main() -> Result<()> { Command::ListUsers => list_users(&pool)?, Command::DeleteUser { username } => delete_user(&pool, &username)?, Command::CreateTenant { - slug, + name, storage_root, quickwit_index, - } => create_tenant(&pool, &slug, storage_root, quickwit_index)?, - Command::DeleteTenant { slug } => delete_tenant(&pool, &slug)?, - Command::AddUserToTenant { username, slug } => add_user_to_tenant(&pool, &username, &slug)?, - Command::RemoveUserFromTenant { username, slug } => { - remove_user_from_tenant(&pool, &username, &slug)? + } => create_tenant(&pool, &name, storage_root, quickwit_index)?, + Command::DeleteTenant { name } => delete_tenant(&pool, &name)?, + Command::AddUserToTenant { username, name } => add_user_to_tenant(&pool, &username, &name)?, + Command::RemoveUserFromTenant { username, name } => { + remove_user_from_tenant(&pool, &username, &name)? } - Command::ReanalyzeDocuments { slug } => reanalyze_documents(&pool, &slug)?, + Command::ReanalyzeDocuments { name } => reanalyze_documents(&pool, &name)?, Command::ListTenants => list_tenants(&pool)?, - Command::DeleteAssets(slug) => delete_assets_for_tenant(&config, &pool, &slug).await?, - Command::QuickwitCreate(slug) => { - quickwit_index(&config, &pool, &slug, Method::POST).await? + Command::DeleteAssets(name) => delete_assets_for_tenant(&config, &pool, &name).await?, + Command::QuickwitCreate(name) => { + quickwit_index(&config, &pool, &name, Method::POST).await? } - Command::QuickwitDelete(slug) => { - quickwit_index(&config, &pool, &slug, Method::DELETE).await? + Command::QuickwitDelete(name) => { + quickwit_index(&config, &pool, &name, Method::DELETE).await? } } @@ -229,8 +229,8 @@ fn list_users(pool: &PgPool) -> Result<()> { let memberships: Vec = user_memberships::table .inner_join(tenants::table) .filter(user_memberships::user_id.eq(user.id)) - .select(tenants::slug) - .order(tenants::slug.asc()) + .select(tenants::name) + .order(tenants::name.asc()) .load(&mut conn)?; if memberships.is_empty() { @@ -267,14 +267,14 @@ fn delete_user(pool: &PgPool, username: &str) -> Result<()> { fn create_tenant( pool: &PgPool, - slug: &str, + name: &str, storage_root_arg: Option, quickwit_index_arg: Option, ) -> Result<()> { let service = TenantService::new(pool.clone()); let tenant = service .create_tenant( - slug, + name, storage_root_arg.as_deref(), quickwit_index_arg.as_deref(), TenantStatus::Creating, @@ -288,7 +288,7 @@ fn create_tenant( println!( "created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}', status '{}'", - tenant.slug, + tenant.name, tenant.id, storage_root, quickwit_index, @@ -297,29 +297,29 @@ fn create_tenant( Ok(()) } -fn delete_tenant(pool: &PgPool, slug: &str) -> Result<()> { +fn delete_tenant(pool: &PgPool, name: &str) -> Result<()> { let mut conn = pool.get().context("failed to get database connection")?; let tenant: Tenant = tenants::table - .filter(tenants::slug.eq(slug)) + .filter(tenants::name.eq(name)) .first(&mut conn) .optional()? - .ok_or_else(|| anyhow!("tenant '{}' not found", slug))?; + .ok_or_else(|| anyhow!("tenant '{}' not found", name))?; let member_exists: bool = select(exists( user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)), )) .get_result(&mut conn)?; if member_exists { - bail!("tenant '{}' still has user memberships", slug); + bail!("tenant '{}' still has user memberships", name); } diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?; - println!("deleted tenant '{}'", slug); + println!("deleted tenant '{}'", name); Ok(()) } -fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> { +fn add_user_to_tenant(pool: &PgPool, username: &str, name: &str) -> Result<()> { let mut conn = pool.get().context("failed to get database connection")?; let user: User = users::table @@ -329,10 +329,10 @@ fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> { .ok_or_else(|| anyhow!("user '{}' not found", username))?; let tenant: Tenant = tenants::table - .filter(tenants::slug.eq(slug)) + .filter(tenants::name.eq(name)) .first(&mut conn) .optional()? - .ok_or_else(|| anyhow!("tenant '{}' not found", slug))?; + .ok_or_else(|| anyhow!("tenant '{}' not found", name))?; let membership = NewUserMembership { id: Uuid::new_v4(), @@ -346,11 +346,11 @@ fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> { .do_nothing() .execute(&mut conn)?; - println!("added user '{}' to tenant '{}'", username, slug); + println!("added user '{}' to tenant '{}'", username, name); Ok(()) } -fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> { +fn remove_user_from_tenant(pool: &PgPool, username: &str, name: &str) -> Result<()> { let mut conn = pool.get().context("failed to get database connection")?; let user: User = users::table @@ -360,10 +360,10 @@ fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result< .ok_or_else(|| anyhow!("user '{}' not found", username))?; let tenant: Tenant = tenants::table - .filter(tenants::slug.eq(slug)) + .filter(tenants::name.eq(name)) .first(&mut conn) .optional()? - .ok_or_else(|| anyhow!("tenant '{}' not found", slug))?; + .ok_or_else(|| anyhow!("tenant '{}' not found", name))?; let removed = diesel::delete( user_memberships::table @@ -373,21 +373,21 @@ fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result< .execute(&mut conn)?; if removed == 0 { - println!("user '{}' was not a member of tenant '{}'", username, slug); + println!("user '{}' was not a member of tenant '{}'", username, name); } else { - println!("removed user '{}' from tenant '{}'", username, slug); + println!("removed user '{}' from tenant '{}'", username, name); } Ok(()) } -fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> { +fn reanalyze_documents(pool: &PgPool, name: &str) -> Result<()> { let mut conn = pool.get().context("failed to get database connection")?; let tenant: Tenant = tenants::table - .filter(tenants::slug.eq(slug)) + .filter(tenants::name.eq(name)) .first(&mut conn) .optional()? - .ok_or_else(|| anyhow!("tenant '{}' not found", slug))?; + .ok_or_else(|| anyhow!("tenant '{}' not found", name))?; let targets: Vec<(Uuid, Uuid)> = documents::table .filter(documents::tenant_id.eq(tenant.id)) @@ -396,7 +396,7 @@ fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> { .load(&mut conn)?; if targets.is_empty() { - println!("tenant '{}' has no active documents", slug); + println!("tenant '{}' has no active documents", name); return Ok(()); } @@ -419,7 +419,7 @@ fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> { println!( "queued {} documents for re-analysis in tenant '{}'", - queued, slug + queued, name ); Ok(()) } @@ -427,7 +427,7 @@ fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> { fn list_tenants(pool: &PgPool) -> Result<()> { let mut conn = pool.get().context("failed to get database connection")?; let tenants: Vec = tenants::table - .order(tenants::slug.asc()) + .order(tenants::name.asc()) .load(&mut conn) .context("failed to load tenants")?; @@ -437,7 +437,7 @@ fn list_tenants(pool: &PgPool) -> Result<()> { } for tenant in tenants { - println!("{} ({})", tenant.slug, tenant.id); + println!("{} ({})", tenant.name, tenant.id); } Ok(()) @@ -446,7 +446,7 @@ fn list_tenants(pool: &PgPool) -> Result<()> { async fn delete_assets_for_tenant( config: &AppConfig, pool: &PgPool, - tenant_slug: &str, + tenant_name: &str, ) -> Result<()> { let s3_client = s3::build_client(config).await?; let storage: Arc = @@ -454,28 +454,28 @@ async fn delete_assets_for_tenant( let mut conn = pool.get().context("failed to get database connection")?; let tenant: Tenant = tenants::table - .filter(tenants::slug.eq(tenant_slug)) + .filter(tenants::name.eq(tenant_name)) .first(&mut conn) .optional() .context("failed to load tenant")? - .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_slug))?; + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_name))?; let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant) - .with_context(|| format!("missing storage root for tenant {}", tenant.slug))?; + .with_context(|| format!("missing storage root for tenant {}", tenant.name))?; let assets: Vec = document_assets::table .filter(document_assets::tenant_id.eq(tenant.id)) .load(&mut conn) - .with_context(|| format!("failed to load assets for tenant {}", tenant.slug))?; + .with_context(|| format!("failed to load assets for tenant {}", tenant.name))?; if assets.is_empty() { - println!("Tenant {}: no assets", tenant.slug); + println!("Tenant {}: no assets", tenant.name); return Ok(()); } println!( "Tenant {} ({}): deleting {} assets…", - tenant.slug, + tenant.name, tenant.id, assets.len() ); @@ -486,13 +486,13 @@ async fn delete_assets_for_tenant( .filter(document_asset_objects::tenant_id.eq(tenant.id)) .filter(document_asset_objects::asset_id.eq_any(&asset_ids)) .load(&mut conn) - .with_context(|| format!("failed to load asset objects for tenant {}", tenant.slug))?; + .with_context(|| format!("failed to load asset objects for tenant {}", tenant.name))?; for object in &objects { if let Err(err) = tenant_storage.delete_object(&object.s3_key).await { eprintln!( "Failed to delete object {} (tenant {}): {err}", - object.s3_key, tenant.slug + object.s3_key, tenant.name ); } } @@ -503,20 +503,20 @@ async fn delete_assets_for_tenant( .filter(document_asset_objects::asset_id.eq_any(&asset_ids)), ) .execute(&mut conn) - .with_context(|| format!("failed to remove asset objects for tenant {}", tenant.slug))?; + .with_context(|| format!("failed to remove asset objects for tenant {}", tenant.name))?; diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id))) .execute(&mut conn) - .with_context(|| format!("failed to remove asset records for tenant {}", tenant.slug))?; + .with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?; - println!("Tenant {}: asset records deleted.", tenant.slug); + println!("Tenant {}: asset records deleted.", tenant.name); Ok(()) } async fn quickwit_index( config: &AppConfig, pool: &PgPool, - slug: &str, + name: &str, method: Method, ) -> Result<()> { let endpoint = config @@ -526,11 +526,11 @@ async fn quickwit_index( let mut conn = pool.get().context("failed to get database connection")?; let tenant: Tenant = tenants::table - .filter(tenants::slug.eq(slug)) + .filter(tenants::name.eq(name)) .first(&mut conn) .optional() .context("failed to query tenants")? - .ok_or_else(|| anyhow!("tenant '{}' not found", slug))?; + .ok_or_else(|| anyhow!("tenant '{}' not found", name))?; let client = Client::new(); let index_id = format!("documents-{}", tenant.id); @@ -549,7 +549,7 @@ async fn quickwit_index( println!( "Tenant '{}' quickwit index set to '{}'.", - tenant.slug, index_id + tenant.name, index_id ); } Method::DELETE => { @@ -566,7 +566,7 @@ async fn quickwit_index( .execute(&mut conn) .context("failed to clear tenant quickwit_index")?; - println!("Tenant '{}' quickwit index cleared.", tenant.slug); + println!("Tenant '{}' quickwit index cleared.", tenant.name); } status => { let body = response.text().await.unwrap_or_default(); diff --git a/backend/src/config.rs b/backend/src/config.rs index 7ce0991..e62986e 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -54,8 +54,8 @@ pub struct AppConfig { pub quickwit_endpoint: Option, #[serde(default)] pub quickwit_index: Option, - #[serde(default = "default_tenant_slug")] - pub default_tenant_slug: String, + #[serde(default = "default_tenant_name")] + pub default_tenant_name: String, #[serde(default = "default_worker_max_document_bytes")] pub worker_max_document_bytes: u64, #[serde(default)] @@ -171,7 +171,7 @@ fn default_aws_region() -> String { "us-east-1".to_string() } -fn default_tenant_slug() -> String { +fn default_tenant_name() -> String { "admin".to_string() } diff --git a/backend/src/models.rs b/backend/src/models.rs index dd92a9f..803ac8b 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -97,7 +97,7 @@ impl FromSql for TenantStatus { #[diesel(primary_key(id))] pub struct Tenant { pub id: Uuid, - pub slug: String, + pub name: String, pub storage_root: Option, pub quickwit_index: Option, pub config: Value, diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index 49b3693..49ce63e 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -684,7 +684,7 @@ pub mod schemas { pub username: String, pub password: String, #[schema(nullable)] - pub preferred_tenant_slug: Option, + pub preferred_tenant_id: Option, } #[derive(Serialize, Deserialize, ToSchema)] @@ -704,7 +704,7 @@ pub mod schemas { #[derive(Serialize, Deserialize, ToSchema)] pub struct TenantSnippet { pub id: Uuid, - pub slug: String, + pub name: String, } #[derive(Serialize, Deserialize, ToSchema)] diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index a8d1db4..035cd1a 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -41,7 +41,7 @@ pub struct LoginRequest { pub username: String, pub password: String, #[serde(default)] - pub preferred_tenant_slug: Option, + pub preferred_tenant_id: Option, } #[derive(Deserialize)] @@ -61,7 +61,7 @@ pub struct LoginResponse { #[derive(Serialize)] pub struct TenantSnippet { pub id: Uuid, - pub slug: String, + pub name: String, } #[derive(Serialize)] @@ -109,9 +109,9 @@ pub async fn signup( insert_user(&mut conn, user_id, username, &password_hash)?; - let tenant_slug = username.to_lowercase(); + let tenant_name = username.to_string(); let tenant = state.tenants.create_tenant( - &tenant_slug, + &tenant_name, None, None, TenantStatus::Creating, @@ -147,13 +147,7 @@ pub async fn login( return Err(AppError::unauthorized()); } - let preferred_slug = payload - .preferred_tenant_slug - .as_ref() - .map(|slug| slug.trim().to_string()) - .filter(|slug| !slug.is_empty()); - - complete_login(&state, &mut conn, &user, preferred_slug) + complete_login(&state, &mut conn, &user, payload.preferred_tenant_id) } pub async fn refresh( @@ -323,10 +317,10 @@ pub async fn list_tenants( let tenants = memberships_dsl::user_memberships .inner_join(tenant_dsl::tenants) .filter(memberships_dsl::user_id.eq(user_id)) - .select((tenant_dsl::id, tenant_dsl::slug)) + .select((tenant_dsl::id, tenant_dsl::name)) .load::<(Uuid, String)>(&mut conn)? .into_iter() - .map(|(id, slug)| TenantSnippet { id, slug }) + .map(|(id, name)| TenantSnippet { id, name }) .collect(); Ok(Json(TenantListResponse { tenants })) @@ -424,7 +418,7 @@ fn complete_login( state: &AppState, conn: &mut PgConnection, user: &User, - preferred_slug: Option, + preferred_tenant_id: Option, ) -> AppResult { let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships .inner_join(tenant_dsl::tenants) @@ -435,10 +429,10 @@ fn complete_login( return Err(AppError::unauthorized()); } - if let Some(ref slug) = preferred_slug { + if let Some(preferred_id) = preferred_tenant_id { if let Some((_, tenant)) = memberships .iter() - .find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug)) + .find(|(_, tenant)| tenant.id == preferred_id) { return issue_session(state, conn, user, tenant.id); } @@ -457,7 +451,7 @@ fn complete_login( .into_iter() .map(|(_, tenant)| TenantSnippet { id: tenant.id, - slug: tenant.slug, + name: tenant.name, }) .collect(); @@ -480,9 +474,9 @@ fn issue_session( .generate_token(user.id, tenant_id, &user.username) .map_err(AppError::from)?; - let tenant_slug: String = tenant_dsl::tenants + let tenant_name: String = tenant_dsl::tenants .find(tenant_id) - .select(tenant_dsl::slug) + .select(tenant_dsl::name) .first(conn) .map_err(AppError::from)?; @@ -509,7 +503,7 @@ fn issue_session( expires_in: state.config.jwt_expiry_minutes * 60, tenant: TenantSnippet { id: tenant_id, - slug: tenant_slug, + name: tenant_name, }, }) .into_response(); diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs index e45bad9..967325a 100644 --- a/backend/src/routes/webdav/mod.rs +++ b/backend/src/routes/webdav/mod.rs @@ -457,11 +457,11 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result(&mut conn) .optional()?; - let (tenant_id, _slug) = match tenant_row { + let (tenant_id, _name) = match tenant_row { Some(row) => row, None => { tracing::warn!( diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 9c54d30..7139df5 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -159,7 +159,7 @@ diesel::table! { tenants (id) { id -> Uuid, - slug -> Text, + name -> Text, storage_root -> Nullable, quickwit_index -> Nullable, config -> Jsonb, diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs index 9882729..edd107a 100644 --- a/backend/src/tenants.rs +++ b/backend/src/tenants.rs @@ -24,9 +24,9 @@ impl TenantRepository { dsl::tenants.find(tenant_id).first(conn).map_err(Into::into) } - pub fn get_by_slug(conn: &mut PgConnection, slug: &str) -> AppResult { + pub fn get_by_name(conn: &mut PgConnection, name: &str) -> AppResult { dsl::tenants - .filter(dsl::slug.eq(slug)) + .filter(dsl::name.eq(name)) .first(conn) .map_err(Into::into) } @@ -47,28 +47,28 @@ impl TenantService { Ok(tenant) } - pub fn get_by_slug(&self, slug: &str) -> AppResult { - let slug_owned = slug.to_owned(); - let tenant = self.load(|conn| TenantRepository::get_by_slug(conn, &slug_owned))?; + pub fn get_by_name(&self, name: &str) -> AppResult { + let name_owned = name.to_owned(); + let tenant = self.load(|conn| TenantRepository::get_by_name(conn, &name_owned))?; Ok(tenant) } - pub fn tenant_id_for_slug(&self, slug: &str) -> AppResult { - Ok(self.get_by_slug(slug)?.id) + pub fn tenant_id_for_name(&self, name: &str) -> AppResult { + Ok(self.get_by_name(name)?.id) } pub fn create_tenant( &self, - slug: &str, + name: &str, storage_root: Option<&str>, quickwit_index: Option<&str>, status: TenantStatus, initial_members: &[Uuid], created_by: Option, ) -> AppResult { - let slug = slug.trim(); - if slug.is_empty() { - return Err(AppError::bad_request("tenant slug must not be empty")); + let name = name.trim(); + if name.is_empty() { + return Err(AppError::bad_request("tenant name must not be empty")); } let mut conn = self.pool.get().map_err(|err| { @@ -77,11 +77,11 @@ impl TenantService { })?; let exists: bool = - select(exists(dsl::tenants.filter(dsl::slug.eq(slug)))).get_result(&mut conn)?; + select(exists(dsl::tenants.filter(dsl::name.eq(name)))).get_result(&mut conn)?; if exists { return Err(AppError::conflict(format!( "tenant '{}' already exists", - slug + name ))); } @@ -92,7 +92,7 @@ impl TenantService { diesel::insert_into(dsl::tenants) .values(( dsl::id.eq(id), - dsl::slug.eq(slug), + dsl::name.eq(name), dsl::storage_root.eq(Some(storage_root.clone())), dsl::quickwit_index.eq(Some(quickwit_index.clone())), dsl::config.eq(json!({})), @@ -150,7 +150,7 @@ impl FromRequestParts for TenantContext { ) -> Result { let tenant = state .tenants - .get_by_slug(&state.config.default_tenant_slug)?; + .get_by_name(&state.config.default_tenant_name)?; Ok(Self { tenant }) } } diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index 1d09c2d..2106c67 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -33,7 +33,8 @@ struct ErrorResponse { #[derive(Deserialize)] struct LoginTenant { - slug: String, + id: Uuid, + name: String, } #[derive(Deserialize)] @@ -51,7 +52,7 @@ struct TenantSelectionResponse { #[derive(Deserialize)] struct TenantSummary { id: Uuid, - slug: String, + name: String, } #[tokio::test] @@ -116,7 +117,7 @@ async fn signup_creates_user_tenant_and_membership() -> Result<()> { .first(conn)?; let tenant: backend::models::Tenant = tenants::table - .filter(tenants::slug.eq("signup-user")) + .filter(tenants::name.eq("signup-user")) .first(conn)?; assert_eq!(tenant.status, TenantStatus::Creating); @@ -419,7 +420,7 @@ async fn refresh_rotates_refresh_token() -> Result<()> { let new_cookie = extract_refresh_cookie(response.headers())?; let body = body_to_vec(response.into_body()).await?; let refreshed: LoginResponse = serde_json::from_slice(&body)?; - assert_eq!(refreshed.tenant.slug, login.tenant.slug); + assert_eq!(refreshed.tenant.name, login.tenant.name); let me_response = app .get("/api/auth/me", Some(&refreshed.access_token)) @@ -492,14 +493,14 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<() let password = "multipass"; let user_id = app.insert_user("multipass", password, "admin").await?; - let secondary_slug = "secondary".to_string(); - let slug_for_insert = secondary_slug.clone(); + let secondary_name = "secondary".to_string(); + let name_for_insert = secondary_name.clone(); let secondary_id = Uuid::new_v4(); app.with_conn(move |conn| { diesel::insert_into(tenants::table) .values(( tenants::id.eq(secondary_id), - tenants::slug.eq(&slug_for_insert), + tenants::name.eq(&name_for_insert), tenants::status.eq(TenantStatus::Active), )) .execute(conn)?; @@ -526,7 +527,7 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<() let secondary = selection .tenants .iter() - .find(|tenant| tenant.slug == secondary_slug) + .find(|tenant| tenant.name == secondary_name) .map(|t| t.id) .context("secondary tenant missing from selection")?; @@ -541,7 +542,7 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<() let session_cookie = extract_refresh_cookie(select_response.headers())?; let select_body = body_to_vec(select_response.into_body()).await?; let login: LoginResponse = serde_json::from_slice(&select_body)?; - assert_eq!(login.tenant.slug, secondary_slug); + assert_eq!(login.tenant.name, secondary_name); let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?; assert_eq!(me_response.status(), StatusCode::OK); diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 3e80dd0..4db13f8 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -140,7 +140,7 @@ impl TestApp { s3_bucket: "test-bucket".to_string(), quickwit_endpoint: None, quickwit_index: None, - default_tenant_slug: "admin".to_string(), + default_tenant_name: "admin".to_string(), worker_max_document_bytes: 200 * 1024 * 1024, webauthn_rp_id: Some("localhost".to_string()), webauthn_origin: Some("http://localhost".to_string()), @@ -193,7 +193,7 @@ impl TestApp { let tenant = self .state .tenants - .get_by_slug(&self.state.config.default_tenant_slug) + .get_by_name(&self.state.config.default_tenant_name) .map_err(|err| anyhow!("default tenant not found: {:?}", err))?; let root = tenant .storage_root @@ -208,7 +208,7 @@ impl TestApp { let tenant_id = self .state .tenants - .tenant_id_for_slug(&self.state.config.default_tenant_slug) + .tenant_id_for_name(&self.state.config.default_tenant_name) .map_err(|err| anyhow!("default tenant not found: {:?}", err))?; self.with_conn(move |conn| { let password_hash = hash_password(&password)?; @@ -264,13 +264,13 @@ impl TestApp { } async fn ensure_default_tenant(&self) -> Result { - let slug_value = self.state.config.default_tenant_slug.clone(); + let name_value = self.state.config.default_tenant_name.clone(); let quickwit_enabled = self.state.config.quickwit_endpoint.is_some(); self.with_conn(move |conn| { use backend::schema::tenants::dsl as tenants_dsl; let existing = tenants_dsl::tenants - .filter(tenants_dsl::slug.eq(&slug_value)) + .filter(tenants_dsl::name.eq(&name_value)) .first::(conn) .optional() .context("failed to load default tenant")?; @@ -302,7 +302,7 @@ impl TestApp { diesel::insert_into(tenants_dsl::tenants) .values(( tenants_dsl::id.eq(new_id), - tenants_dsl::slug.eq(&slug_value), + tenants_dsl::name.eq(&name_value), tenants_dsl::storage_root.eq(Some(root)), tenants_dsl::quickwit_index.eq(quickwit_value), tenants_dsl::status.eq(TenantStatus::Active), @@ -352,7 +352,7 @@ impl TestApp { #[derive(Deserialize)] struct TenantSummary { id: Uuid, - _slug: String, + name: String, } #[derive(Deserialize)] diff --git a/backend/tests/tags_flow.rs b/backend/tests/tags_flow.rs index 07b40dd..4820a5e 100644 --- a/backend/tests/tags_flow.rs +++ b/backend/tests/tags_flow.rs @@ -211,7 +211,7 @@ async fn tags_are_isolated_between_tenants() -> Result<()> { diesel::insert_into(tenants_dsl::tenants) .values(( tenants_dsl::id.eq(tenant_b_id), - tenants_dsl::slug.eq("tenant-b"), + tenants_dsl::name.eq("tenant-b"), tenants_dsl::storage_root.eq(Some(storage_root)), tenants_dsl::status.eq(TenantStatus::Active), )) diff --git a/docs/api.txt b/docs/api.txt index a391d93..c844778 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer < Authentication -------------- -- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, slug } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead. -- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, slug } }`. +- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, name } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead. +- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, name } }`. - POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie. - GET /api/auth/me - Return the authenticated principal payload. diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index 22452e4..cdb861f 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -123,10 +123,11 @@ const readTransferData = (dataTransfer, mimeTypes) => { if (raw) { return raw; } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + if (DEBUG_DROP) { + console.warn('[skeuo] readTransferData failed for type', type, error); + } + } } return null; }; @@ -141,10 +142,9 @@ const parseTagTransferPayload = (event) => { } try { return JSON.parse(raw); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[skeuo] parseTagTransferPayload failed', error); + } return null; }; @@ -465,10 +465,9 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { view = createAssetView(asset); metadata = view.getPrimaryMetadata(); } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[skeuo] ensureDocumentSize metadata fetch failed', error); + } } if (!hasDimensions(metadata)) { @@ -509,7 +508,7 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { return metadataMap; }; -const seededRandom = (input) => { +function seededRandom(input) { const text = String(input); let hash = 2166136261; for (let index = 0; index < text.length; index += 1) { @@ -517,16 +516,18 @@ const seededRandom = (input) => { hash = Math.imul(hash, 16777619); } return (hash >>> 0) / 4294967295; -}; +} -const randomRangeFromSeed = (seedKey, min, max) => { +function randomRangeFromSeed(seedKey, min, max) { const span = max - min; if (span <= 0) return min; const seed = seededRandom(seedKey); return min + seed * span; -}; +} -const buildKey = (docId, suffix) => `${docId}::${suffix}`; +function buildKey(docId, suffix) { + return `${docId}::${suffix}`; +} const clampCardDimensions = (width, height) => { const w = Number(width); @@ -758,10 +759,11 @@ const DesktopWorkspace = ({ if (DEBUG_FOCUS) { console.log('[skeuo] focusCanvas: applied focus. activeElement:', document?.activeElement); } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + if (DEBUG_FOCUS) { + console.warn('[skeuo] focusTarget failed to focus canvas', error); + } + } }; if (typeof window === 'undefined') { @@ -1551,20 +1553,18 @@ const syncLayoutSnapshot = useCallback(() => { if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'copyMove'; } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[skeuo] Failed to set drag effect', error); + } const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id }); try { event.dataTransfer?.setData('application/x-papercrate-tag', payload); event.dataTransfer?.setData('text/papercrate-tag', payload); event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[skeuo] Failed to populate drag data for tag', error); + } const pending = pendingDocTagDragRef.current; const node = event.currentTarget; @@ -1593,10 +1593,9 @@ const syncLayoutSnapshot = useCallback(() => { if (preview && event.dataTransfer) { try { event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[skeuo] Failed to set drag image', error); + } } } draggingTagRef.current = { diff --git a/frontend/src/correspondents/CorrespondentsPanel.jsx b/frontend/src/correspondents/CorrespondentsPanel.jsx index db383b8..835705e 100644 --- a/frontend/src/correspondents/CorrespondentsPanel.jsx +++ b/frontend/src/correspondents/CorrespondentsPanel.jsx @@ -38,10 +38,11 @@ function CorrespondentsPanel({ try { await onUpdate(editingId, { name: trimmed }); cancelEdit(); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + onNotify?.('Failed to update correspondent.', 'error'); + console.error('[correspondents] update failed', error); + setSaving(false); + } }, [editingId, draftName, onUpdate, cancelEdit, onNotify]); const handleDelete = useCallback( @@ -53,16 +54,14 @@ function CorrespondentsPanel({ if (editingId === correspondent.id) { cancelEdit(); } - } catch ( - // eslint-disable-next-line no-empty - error - ) { - return; + } catch (error) { + onNotify?.('Failed to delete correspondent.', 'error'); + console.error('[correspondents] delete failed', error); } finally { setDeletingId(null); } }, - [onDelete, editingId, cancelEdit], + [onDelete, editingId, cancelEdit, onNotify], ); const handleCreate = useCallback( @@ -77,11 +76,9 @@ function CorrespondentsPanel({ try { await onCreate({ name: trimmed }); setCreateName(''); - } catch ( - // eslint-disable-next-line no-empty - error - ) { - return; + } catch (error) { + onNotify?.('Failed to create correspondent.', 'error'); + console.error('[correspondents] create failed', error); } finally { setCreating(false); } diff --git a/frontend/src/desktop/events.js b/frontend/src/desktop/events.js index 4fb85ac..043c99f 100644 --- a/frontend/src/desktop/events.js +++ b/frontend/src/desktop/events.js @@ -4,14 +4,12 @@ export const preventAll = (event) => { } try { event.preventDefault(); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[events] preventDefault failed', error); + } try { event.stopPropagation(); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[events] stopPropagation failed', error); + } }; diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index 3a90dd4..97fa8ff 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -35,16 +35,17 @@ const useDocumentDrag = () => { if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') { try { capturedTarget.releasePointerCapture(pointerId); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + if (debugDrag) { + console.warn('[skeuo] releasePointerCapture failed', error); + } + } } dragStateRef.current = null; setDraggingId((current) => (current === state.docId ? null : current)); syncLayoutSnapshot(); }, - [setDraggingId, syncLayoutSnapshot], + [debugDrag, setDraggingId, syncLayoutSnapshot], ); const handlePointerDown = useCallback( @@ -83,10 +84,11 @@ const useDocumentDrag = () => { if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') { try { capturedTarget.setPointerCapture(event.pointerId); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + if (debugDrag) { + console.warn('[skeuo] setPointerCapture failed', error); + } + } } dragStateRef.current = { docId, diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index bee89fd..1d21c97 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -200,11 +200,7 @@ const PreviewStack = ({ onOpenPreview, onZoomPreview, }) => { - if (!items.length) { - return {emptyMessage}; - } - - const limited = items.slice(0, maxItems); + const limited = useMemo(() => items.slice(0, maxItems), [items, maxItems]); const hasMultiple = limited.length > 1; const preparedItems = useMemo( () => @@ -215,6 +211,10 @@ const PreviewStack = ({ [limited], ); + if (!limited.length) { + return {emptyMessage}; + } + return (
{preparedItems.map(({ entry, angle }, index) => { @@ -329,6 +329,11 @@ const DetailPanel = ({ const [titleError, setTitleError] = useState(null); const [zoomedPreview, setZoomedPreview] = useState(null); + const bulkDocumentIds = useMemo( + () => selectedDocuments.map((doc) => doc?.id).filter(Boolean), + [selectedDocuments], + ); + useEffect(() => { if (!singleDoc) { setTitleEditDocId(null); @@ -1097,7 +1102,7 @@ const DetailPanel = ({ event.preventDefault(); event.stopPropagation(); }; - const documentIds = Array.from(new Set(selectedDocuments.map((doc) => doc?.id).filter(Boolean))); + const documentIds = bulkDocumentIds; return ( <> @@ -1224,7 +1229,7 @@ const DetailPanel = ({ className="icon-button ghost" onClick={(event) => { event.stopPropagation(); - onBulkReanalyze(documentIds); + onBulkReanalyze(bulkDocumentIds); }} aria-label="Re-run analysis for selection" title="Re-run analysis for selection" diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index 7729e75..367ff6a 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -392,10 +392,9 @@ const DocumentsTable = ({ if (parsed?.id && onDocumentTagDrop) { onDocumentTagDrop(documentId, parsed); } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[documents] Failed to parse tag drop payload', error); + } }, [isTagDragEvent, onDocumentTagDrop], ); @@ -714,10 +713,9 @@ const DocumentsTable = ({ event.dataTransfer?.setData('application/x-papercrate-tag', payload); event.dataTransfer?.setData('text/papercrate-tag', payload); event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[documents] Failed to configure tag drag payload', error); + } }} onDragEnd={(event) => { event.stopPropagation(); @@ -925,21 +923,20 @@ const DocumentsTable = ({ onDragStart={(event) => { event.stopPropagation(); try { - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'copyMove'; - } - const payload = JSON.stringify({ - id: tag.id, - label: tag.label, - sourceDocId: doc.id, - }); - event.dataTransfer?.setData('application/x-papercrate-tag', payload); - event.dataTransfer?.setData('text/papercrate-tag', payload); - event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'copyMove'; + } + const payload = JSON.stringify({ + id: tag.id, + label: tag.label, + sourceDocId: doc.id, + }); + event.dataTransfer?.setData('application/x-papercrate-tag', payload); + event.dataTransfer?.setData('text/papercrate-tag', payload); + event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); + } catch (error) { + console.warn('[documents] Failed to configure tag drag payload', error); + } }} onDragEnd={(event) => { event.stopPropagation(); diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 7cd88f0..d922493 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -53,10 +53,9 @@ if (storage) { if (rawTenant) { STORED_TENANT = JSON.parse(rawTenant); } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} +} catch (error) { + console.warn('[app] Failed to parse stored tenant metadata', error); +} } if (STORED_TOKEN) { api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; @@ -428,7 +427,7 @@ const LoginView = ({ : 'login-card__tenant-button' } > - {tenant.slug} + {tenant.name} ))}
@@ -492,7 +491,7 @@ const AppLayout = () => { const routeDocumentId = docMatch?.params?.documentId || null; const previewDocumentId = routeDocumentId; const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState; - const tenantSlug = tenant?.slug || null; + const tenantName = tenant?.name || tenant?.slug || null; const currentTenantId = tenant?.id || null; const [status, setStatus] = useState(null); const setStatusMessage = useCallback((message, variant = 'info') => { @@ -586,6 +585,8 @@ const AppLayout = () => { } }, [appDispatch]); const [searchResults, setSearchResults] = useState(null); + const [previewEntries, setPreviewEntries] = useState(() => new Map()); + const previewInflightRef = useRef(new Map()); const [tags, setTags] = useState([]); const [correspondents, setCorrespondents] = useState([]); const [webdavTokens, setWebdavTokens] = useState([]); @@ -596,6 +597,7 @@ const AppLayout = () => { const [searchQuery, setSearchQuery] = useState(''); const [activeTagFilters, setActiveTagFilters] = useState([]); const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); const documentsRouteMatch = useMatch('/documents'); const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId'); const documentsDetailRouteMatch = useMatch('/documents/:documentId'); @@ -656,7 +658,6 @@ const AppLayout = () => { folderName: DEFAULT_FOLDER_NAME, }); const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null); - const [searchLoading, setSearchLoading] = useState(false); const shellRef = useRef(null); const assetManagerRef = useRef(null); if (!assetManagerRef.current) { @@ -1951,6 +1952,21 @@ const AppLayout = () => { [refreshCorrespondents, notifyApiError, setStatusMessage, mapDocumentCaches], ); + const refreshCurrentFolder = useCallback(async () => { + setLoading(true); + try { + const contents = await ensureFolderData(selectedFolder, { + force: true, + prefetchDepth: 1, + }); + applySelectedFolder(selectedFolder, contents); + } catch (error) { + notifyApiError(error, 'Failed to refresh folder.'); + } finally { + setLoading(false); + } + }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]); + const handleDocumentCorrespondentAttach = useCallback( async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => { if (!documentId || !correspondentId) { @@ -2033,10 +2049,10 @@ const AppLayout = () => { if (input) { input.value = ''; } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + setStatusMessage('Failed to assign correspondent.', 'error'); + console.error('[documents] assign correspondent failed', error); + } }, [ handleCorrespondentCreate, @@ -2286,21 +2302,6 @@ const AppLayout = () => { selectFolder, ]); - const refreshCurrentFolder = useCallback(async () => { - setLoading(true); - try { - const contents = await ensureFolderData(selectedFolder, { - force: true, - prefetchDepth: 1, - }); - applySelectedFolder(selectedFolder, contents); - } catch (error) { - notifyApiError(error, 'Failed to refresh folder.'); - } finally { - setLoading(false); - } - }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]); - const resolveTargetDocumentIds = useCallback( (candidateIds) => { const normalized = Array.isArray(candidateIds) @@ -2671,8 +2672,6 @@ const AppLayout = () => { ); const folderPathCacheRef = useRef(new Map()); - const [previewEntries, setPreviewEntries] = useState(() => new Map()); - const previewInflightRef = useRef(new Map()); const ensureFolderPathOnServer = useCallback( async (baseFolderId, segments) => { @@ -2919,10 +2918,9 @@ const AppLayout = () => { event.dataTransfer.setData('application/x-papercrate-folder', folderSelection[0]); } } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[documents] Failed to populate drag payload', error); + } if (previewNode) { const width = previewNode.offsetWidth || 96; const height = previewNode.offsetHeight || 96; @@ -2993,10 +2991,9 @@ const AppLayout = () => { JSON.stringify(effectiveDocumentSelection), ); } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[folders] Failed to set drag payload', error); + } const previewDocs = effectiveDocumentSelection .map((id) => documentLookup.get(id) || null) @@ -3112,14 +3109,14 @@ const AppLayout = () => { const readAllEntries = async (reader) => { const entries = []; - while (true) { + let batch = []; + do { // eslint-disable-next-line no-await-in-loop - const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); - if (!batch.length) { - break; + batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); + if (batch.length) { + entries.push(...batch); } - entries.push(...batch); - } + } while (batch.length); return entries; }; @@ -4424,10 +4421,9 @@ const AppLayout = () => { folderIds = parsed.filter(Boolean); } } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[folders] Failed to parse folder list drag payload', error); + } if (!folderIds.length) { let folderSourceId = draggedFolderId; @@ -4436,10 +4432,9 @@ const AppLayout = () => { if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) { folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder'); } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[folders] Failed to read folder id from drag payload', error); + } } if (folderSourceId) { @@ -4480,21 +4475,19 @@ const AppLayout = () => { docIds = parsed.filter(Boolean); } } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + } catch (error) { + console.warn('[documents] Failed to parse document list drag payload', error); + } if (!docIds.length) { - try { - const single = event.dataTransfer.getData('application/x-papercrate-doc'); - if (single) { - docIds = [single]; - } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} + try { + const single = event.dataTransfer.getData('application/x-papercrate-doc'); + if (single) { + docIds = [single]; + } + } catch (error) { + console.warn('[documents] Failed to read single document drag payload', error); + } } if (!docIds.length && draggedDocumentIds.length) { @@ -4551,159 +4544,6 @@ const AppLayout = () => { setStatusMessage, ], ); - const node = folderNodes.get(folderId); - const nextExpanded = !(node?.expanded ?? false); - if (nextExpanded) { - try { - await ensureFolderData(folderId, { - includeDocuments: false, - prefetchDepth: 1, - }); - } catch (error) { - notifyApiError(error, 'Failed to load folder.'); - } - } else if (node && !node.loaded) { - try { - await ensureFolderData(folderId, { - includeDocuments: false, - prefetchDepth: 1, - }); - } catch (error) { - notifyApiError(error, 'Failed to load folder.'); - } - } - setFolderNodes((prev) => { - const next = new Map(prev); - const current = next.get(folderId); - if (!current) return prev; - next.set(folderId, { ...current, expanded: nextExpanded }); - return next; - }); - }, - onSelect: selectFolder, - onDrop: async (event, folderId) => { - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.classList.remove('is-drop-target'); - - let folderIds = []; - try { - const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list'); - if (rawFolderList) { - const parsed = JSON.parse(rawFolderList); - if (Array.isArray(parsed)) { - folderIds = parsed.filter(Boolean); - } - } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} - - if (!folderIds.length) { - let folderSourceId = draggedFolderId; - if (!folderSourceId) { - try { - if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) { - folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder'); - } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} - } - - if (folderSourceId) { - folderIds = [folderSourceId]; - } - } - - folderIds = Array.from(new Set(folderIds.filter(Boolean))); - - if (folderIds.length) { - setDraggedFolderId(null); - const invalidMove = folderIds.some((sourceId) => isInvalidFolderDrop(sourceId, folderId)); - if (invalidMove) { - setStatusMessage( - 'Cannot move a folder into itself or one of its descendants.', - 'error', - ); - return; - } - - for (const sourceId of folderIds) { - // eslint-disable-next-line no-await-in-loop - await moveFolder(sourceId, folderId); - } - } - - if (hasFiles(event)) { - await handleFileDrop(event.dataTransfer, folderId); - return; - } - - let docIds = []; - try { - const raw = event.dataTransfer.getData('application/x-papercrate-doc-list'); - if (raw) { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - docIds = parsed.filter(Boolean); - } - } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} - - if (!docIds.length) { - try { - const single = event.dataTransfer.getData('application/x-papercrate-doc'); - if (single) { - docIds = [single]; - } - } catch ( - // eslint-disable-next-line no-empty - error - ) {} - } - - if (!docIds.length && draggedDocumentIds.length) { - docIds = draggedDocumentIds; - } - - docIds = Array.from(new Set(docIds)); - - if (!docIds.length || folderId === selectedFolder) { - return; - } - - setDraggedDocumentIds([]); - await moveDocumentsToFolder(docIds, folderId); - }, - onDragOver: (event, folderId) => { - const folderDragActive = Boolean(draggedFolderId); - if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) { - return; - } - - if (hasFiles(event)) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; - event.currentTarget.classList.add('is-drop-target'); - return; - } - - if (draggedDocumentIds.length || folderDragActive) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - event.currentTarget.classList.add('is-drop-target'); - } - }, - onDragLeave: (event) => { - event.currentTarget.classList.remove('is-drop-target'); - }, - }; const selectedDocument = useMemo(() => { if (!focusedDocumentId) { @@ -5163,7 +5003,7 @@ const AppLayout = () => { await Promise.all([refreshTags(), refreshCorrespondents()]); await loadFolder('root', { showLoading: false, preserveSearch: false }); - const tenantLabel = data?.tenant?.slug || data?.tenant?.id || 'tenant'; + const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant'; setStatusMessage(`Switched to ${tenantLabel}.`, 'info'); } catch (error) { notifyApiError(error, 'Failed to switch tenant.'); @@ -5314,7 +5154,7 @@ const AppLayout = () => { isFilterActive, onLogout: handleLogout, status, - tenantSlug, + tenantName, tenants: tenantOptions, activeTenantId: currentTenantId, onSelectTenant: handleTenantSelect, @@ -5347,7 +5187,7 @@ const AppLayout = () => { status, tags, tenantOptions, - tenantSlug, + tenantName, toggleCorrespondentFilter, toggleTagFilter, ], diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 2890977..b286276 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -154,7 +154,7 @@ const Sidebar = ({ onLogout, status, onCollapse, - tenantSlug, + tenantName, tenants = [], activeTenantId = null, onSelectTenant, @@ -308,7 +308,7 @@ const Sidebar = ({ > Papercrate - {tenantSlug ? / {tenantSlug} : null} + {tenantName ? / {tenantName} : null} { const tenantId = tenant?.id || null; const isActive = tenantId === activeTenantId; + const tenantLabel = tenant?.name || tenant?.slug || tenantId || 'Tenant'; return ( ); }) @@ -437,26 +436,25 @@ const Sidebar = ({ style={style || undefined} onClick={() => handleToggleTag(tag.id)} aria-pressed={isActive} - draggable - onDragStart={(event) => { - try { - const payload = JSON.stringify({ - id: tag.id, - label: tag.label, - color: tag.color || null, - }); - event.dataTransfer.effectAllowed = 'copy'; - event.dataTransfer.setData('application/x-papercrate-tag', payload); - event.dataTransfer.setData('text/papercrate-tag', payload); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} - }} - > - {tag.label} - - ); + draggable + onDragStart={(event) => { + try { + const payload = JSON.stringify({ + id: tag.id, + label: tag.label, + color: tag.color || null, + }); + event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.setData('application/x-papercrate-tag', payload); + event.dataTransfer.setData('text/papercrate-tag', payload); + } catch (error) { + console.warn('[sidebar] Failed to set tag drag payload', error); + } + }} + > + {tag.label} + + ); })} diff --git a/frontend/src/utils/ocr.js b/frontend/src/utils/ocr.js index 4501ee4..3acbb2c 100644 --- a/frontend/src/utils/ocr.js +++ b/frontend/src/utils/ocr.js @@ -23,15 +23,11 @@ export async function resolveOcrTextUrl({ let asset = pickAsset(docRef, getDocumentAsset); if (!asset && typeof ensurePreviewData === 'function') { - try { - const refreshed = await ensurePreviewData(docRef.id); - if (refreshed) { - docRef = refreshed; - } - asset = pickAsset(docRef, getDocumentAsset); - } catch (error) { - throw error; + const refreshed = await ensurePreviewData(docRef.id); + if (refreshed) { + docRef = refreshed; } + asset = pickAsset(docRef, getDocumentAsset); } if (!asset) {