diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs new file mode 100644 index 0000000000..ce0aaad76c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs @@ -0,0 +1,54 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +/// Canonical wire shape for a folder. Used by single-folder GETs, create +/// responses, list rows, and subfolder list rows. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct FolderInfo { + pub token: FolderToken, + pub name: String, + pub owner_user_token: UserToken, + pub maybe_parent_folder_token: Option, + + pub maybe_last_media_file_token_1: Option, + pub maybe_last_media_file_token_2: Option, + pub maybe_last_media_file_token_3: Option, + pub maybe_last_media_file_token_4: Option, + + pub maybe_cover_image_custom_media_token: Option, + + pub maybe_color_code: Option, + pub has_star: bool, + + pub created_at: DateTime, + pub updated_at: DateTime, + + /// True when `maybe_parent_folder_token` is set but the referenced + /// parent row is missing or soft-deleted. + pub is_orphaned: bool, +} + +/// Canonical wire shape for a media file inside a folder. Lean enough for +/// thumbnail rendering; callers that need richer fields should hit the +/// media-file batch-get endpoint. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct FolderMediaFileInfo { + pub media_file_token: MediaFileToken, + pub added_to_folder_at: DateTime, + + pub media_type: String, + pub media_class: String, + pub maybe_mime_type: Option, + pub public_bucket_directory_hash: String, + pub maybe_public_bucket_prefix: Option, + pub maybe_public_bucket_extension: Option, + pub maybe_frame_width: Option, + pub maybe_frame_height: Option, + pub maybe_duration_millis: Option, + pub maybe_title: Option, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/folder.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/folder.rs new file mode 100644 index 0000000000..3b0aa5898b --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/folder.rs @@ -0,0 +1,93 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use tokens::tokens::folders::FolderToken; + +use crate::folders::common::FolderInfo; + +// ── POST /v1/folders/create ── + +#[derive(Deserialize, ToSchema)] +pub struct CreateFolderRequest { + pub name: String, + pub maybe_parent_folder_token: Option, + pub maybe_color_code: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct CreateFolderSuccessResponse { + pub success: bool, + pub folder: FolderInfo, +} + +// ── GET /v1/folders/list_all ── + +#[derive(Deserialize, IntoParams)] +pub struct ListFoldersQueryParams { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ListFoldersSuccessResponse { + pub success: bool, + pub folders: Vec, + pub maybe_cursor: Option, +} + +// ── GET /v1/folders/folder/{folder_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct FolderPathInfo { + pub folder_token: String, +} + +#[derive(Serialize, ToSchema)] +pub struct GetFolderSuccessResponse { + pub success: bool, + pub folder: FolderInfo, +} + +// ── PUT /v1/folders/folder/{folder_token}/rename ── + +#[derive(Deserialize, ToSchema)] +pub struct RenameFolderRequest { + pub new_name: String, +} + +#[derive(Serialize, ToSchema)] +pub struct RenameFolderSuccessResponse { + pub success: bool, +} + +// ── PUT /v1/folders/folder/{folder_token}/star ── + +#[derive(Deserialize, ToSchema)] +pub struct SetFolderStarRequest { + pub has_star: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct SetFolderStarSuccessResponse { + pub success: bool, +} + +// ── PUT /v1/folders/folder/{folder_token}/color_code ── + +#[derive(Deserialize, ToSchema)] +pub struct SetFolderColorCodeRequest { + /// `None` clears the color tag. + pub maybe_color_code: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct SetFolderColorCodeSuccessResponse { + pub success: bool, +} + +// ── DELETE /v1/folders/folder/{folder_token} ── + +#[derive(Serialize, ToSchema)] +pub struct DeleteFolderSuccessResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs new file mode 100644 index 0000000000..bf083c6a79 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs @@ -0,0 +1,58 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::folders::common::FolderMediaFileInfo; + +// ── GET /v1/folders/media_files/{folder_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct FolderMediaFilesPathInfo { + pub folder_token: String, +} + +#[derive(Deserialize, IntoParams)] +pub struct ListFolderMediaFilesQueryParams { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ListFolderMediaFilesSuccessResponse { + pub success: bool, + pub media_files: Vec, + pub maybe_cursor: Option, +} + +// ── PUT /v1/folders/media_files/{folder_token}/bulk_add ── + +#[derive(Deserialize, ToSchema)] +pub struct BulkAddFolderMediaFilesRequest { + /// Media files to add to the folder. Tokens that don't exist or are + /// soft-deleted are silently skipped. Already-added rows are no-ops + /// (idempotent via INSERT IGNORE on the composite primary key). + pub media_file_tokens: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkAddFolderMediaFilesSuccessResponse { + pub success: bool, + /// Tokens that pre-existed (existed + not deleted). Already-added rows + /// are included since the operation is idempotent. + pub accepted_media_file_tokens: Vec, +} + +// ── PUT /v1/folders/media_files/{folder_token}/bulk_remove ── + +#[derive(Deserialize, ToSchema)] +pub struct BulkRemoveFolderMediaFilesRequest { + pub media_file_tokens: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkRemoveFolderMediaFilesSuccessResponse { + pub success: bool, + /// How many membership rows were actually hard-deleted. + pub removed_count: u64, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/mod.rs new file mode 100644 index 0000000000..46416cdc02 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/mod.rs @@ -0,0 +1,4 @@ +pub mod common; +pub mod folder; +pub mod media_files; +pub mod subfolder; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/subfolder.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/subfolder.rs new file mode 100644 index 0000000000..db78358965 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/subfolder.rs @@ -0,0 +1,61 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use tokens::tokens::folders::FolderToken; + +use crate::folders::common::FolderInfo; + +// ── GET /v1/folders/subfolders/{folder_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct SubfolderPathInfo { + pub folder_token: String, +} + +#[derive(Deserialize, IntoParams)] +pub struct ListSubfoldersQueryParams { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ListSubfoldersSuccessResponse { + pub success: bool, + pub subfolders: Vec, + pub maybe_cursor: Option, +} + +// ── PUT /v1/folders/subfolders/{folder_token}/bulk_add ── + +#[derive(Deserialize, ToSchema)] +pub struct BulkAddSubfoldersRequest { + /// The folders to reparent under the URL `folder_token`. Tokens that + /// don't exist, aren't owned by the caller, are soft-deleted, or are + /// the parent itself are silently skipped (idempotent). + pub subfolder_tokens: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkAddSubfoldersSuccessResponse { + pub success: bool, + /// Tokens that were actually accepted (existed + owned + not deleted + + /// not the parent itself). Already-parented tokens are included here + /// since the operation is idempotent. + pub accepted_subfolder_tokens: Vec, +} + +// ── PUT /v1/folders/subfolders/{folder_token}/bulk_remove ── + +#[derive(Deserialize, ToSchema)] +pub struct BulkRemoveSubfoldersRequest { + /// Folders to unparent. Only rows currently parented to the URL + /// `folder_token` are affected; others are silently skipped. + pub subfolder_tokens: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkRemoveSubfoldersSuccessResponse { + pub success: bool, + /// How many rows were actually unparented. + pub removed_count: u64, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs index 3828a3aa94..41d84b638b 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs @@ -2,6 +2,7 @@ pub mod analytics; pub mod characters; pub mod common; pub mod credits; +pub mod folders; pub mod generate; pub mod jobs; pub mod media_file; diff --git a/crates/schema/database/mysql_queries/src/queries/folders/folder/folder_row.rs b/crates/schema/database/mysql_queries/src/queries/folders/folder/folder_row.rs new file mode 100644 index 0000000000..1688e10e01 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/folder/folder_row.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, Utc}; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +/// Materialized representation of a `folders` row plus the derived +/// `is_orphaned` flag (true when `maybe_parent_folder_token` is set but the +/// referenced parent row is missing or soft-deleted). +#[derive(Debug, Clone)] +pub struct FolderRow { + pub id: u64, + pub token: FolderToken, + pub name: String, + pub owner_user_token: UserToken, + pub maybe_parent_folder_token: Option, + pub maybe_last_media_file_token_1: Option, + pub maybe_last_media_file_token_2: Option, + pub maybe_last_media_file_token_3: Option, + pub maybe_last_media_file_token_4: Option, + pub maybe_cover_image_custom_media_token: Option, + pub maybe_color_code: Option, + pub has_star: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub is_orphaned: bool, +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/folder/get_folder_for_owner.rs b/crates/schema/database/mysql_queries/src/queries/folders/folder/get_folder_for_owner.rs new file mode 100644 index 0000000000..24833df435 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/folder/get_folder_for_owner.rs @@ -0,0 +1,69 @@ +use sqlx::MySqlPool; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +use crate::queries::folders::folder::folder_row::FolderRow; + +/// Fetch a single (live) folder by token, scoped to an owner. Returns +/// `Ok(None)` if the folder doesn't exist, is soft-deleted, or is owned +/// by a different user (don't leak existence of others' folders). +pub async fn get_folder_for_owner( + folder_token: &FolderToken, + owner_user_token: &UserToken, + pool: &MySqlPool, +) -> Result, sqlx::Error> { + let result = sqlx::query!( + r#" +SELECT + f.id as `id: u64`, + f.token as `token: FolderToken`, + f.name, + f.owner_user_token as `owner_user_token: UserToken`, + f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`, + f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`, + f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`, + f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`, + f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`, + f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`, + f.maybe_color_code, + f.has_star as `has_star: bool`, + f.created_at, + f.updated_at, + ( + f.maybe_parent_folder_token IS NOT NULL + AND (p.token IS NULL OR p.maybe_deleted_at IS NOT NULL) + ) as `is_orphaned: bool` +FROM folders f +LEFT JOIN folders p + ON p.token = f.maybe_parent_folder_token +WHERE f.token = ? + AND f.owner_user_token = ? + AND f.maybe_deleted_at IS NULL +LIMIT 1 + "#, + folder_token.as_str(), + owner_user_token.as_str(), + ) + .fetch_optional(pool) + .await?; + + Ok(result.map(|r| FolderRow { + id: r.id, + token: r.token, + name: r.name, + owner_user_token: r.owner_user_token, + maybe_parent_folder_token: r.maybe_parent_folder_token, + maybe_last_media_file_token_1: r.maybe_last_media_file_token_1, + maybe_last_media_file_token_2: r.maybe_last_media_file_token_2, + maybe_last_media_file_token_3: r.maybe_last_media_file_token_3, + maybe_last_media_file_token_4: r.maybe_last_media_file_token_4, + maybe_cover_image_custom_media_token: r.maybe_cover_image_custom_media_token, + maybe_color_code: r.maybe_color_code, + has_star: r.has_star, + created_at: r.created_at, + updated_at: r.updated_at, + is_orphaned: r.is_orphaned, + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/folder/insert_folder.rs b/crates/schema/database/mysql_queries/src/queries/folders/folder/insert_folder.rs new file mode 100644 index 0000000000..989cd79e33 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/folder/insert_folder.rs @@ -0,0 +1,37 @@ +use sqlx::MySqlPool; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::users::UserToken; + +pub struct InsertFolderArgs<'a> { + pub token: &'a FolderToken, + pub name: &'a str, + pub owner_user_token: &'a UserToken, + pub maybe_parent_folder_token: Option<&'a FolderToken>, + pub maybe_color_code: Option<&'a str>, +} + +pub async fn insert_folder( + args: InsertFolderArgs<'_>, + pool: &MySqlPool, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" +INSERT INTO folders +SET + token = ?, + name = ?, + owner_user_token = ?, + maybe_parent_folder_token = ?, + maybe_color_code = ? + "#, + args.token.as_str(), + args.name, + args.owner_user_token.as_str(), + args.maybe_parent_folder_token.map(|t| t.as_str()), + args.maybe_color_code, + ) + .execute(pool) + .await?; + Ok(()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/folder/list_folders_for_user.rs b/crates/schema/database/mysql_queries/src/queries/folders/folder/list_folders_for_user.rs new file mode 100644 index 0000000000..3f36cbf215 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/folder/list_folders_for_user.rs @@ -0,0 +1,141 @@ +use sqlx::MySqlPool; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +use crate::queries::folders::folder::folder_row::FolderRow; + +pub struct ListFoldersForUserArgs<'a> { + pub owner_user_token: &'a UserToken, + pub maybe_cursor_id: Option, + pub limit: u32, + pub pool: &'a MySqlPool, +} + +/// List all live folders owned by a user, newest first. Includes the +/// derived `is_orphaned` flag computed by joining the table against itself +/// on `maybe_parent_folder_token` — orphaned means the parent row is +/// missing or soft-deleted. +pub async fn list_folders_for_user( + args: ListFoldersForUserArgs<'_>, +) -> Result, sqlx::Error> { + let limit = args.limit as i64; + + let rows = match args.maybe_cursor_id { + Some(cursor_id) => { + sqlx::query!( + r#" +SELECT + f.id as `id: u64`, + f.token as `token: FolderToken`, + f.name, + f.owner_user_token as `owner_user_token: UserToken`, + f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`, + f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`, + f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`, + f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`, + f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`, + f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`, + f.maybe_color_code, + f.has_star as `has_star: bool`, + f.created_at, + f.updated_at, + ( + f.maybe_parent_folder_token IS NOT NULL + AND (p.token IS NULL OR p.maybe_deleted_at IS NOT NULL) + ) as `is_orphaned: bool` +FROM folders f +LEFT JOIN folders p + ON p.token = f.maybe_parent_folder_token +WHERE f.owner_user_token = ? + AND f.maybe_deleted_at IS NULL + AND f.id < ? +ORDER BY f.id DESC +LIMIT ? + "#, + args.owner_user_token.as_str(), + cursor_id, + limit, + ) + .fetch_all(args.pool) + .await? + .into_iter() + .map(|r| FolderRow { + id: r.id, + token: r.token, + name: r.name, + owner_user_token: r.owner_user_token, + maybe_parent_folder_token: r.maybe_parent_folder_token, + maybe_last_media_file_token_1: r.maybe_last_media_file_token_1, + maybe_last_media_file_token_2: r.maybe_last_media_file_token_2, + maybe_last_media_file_token_3: r.maybe_last_media_file_token_3, + maybe_last_media_file_token_4: r.maybe_last_media_file_token_4, + maybe_cover_image_custom_media_token: r.maybe_cover_image_custom_media_token, + maybe_color_code: r.maybe_color_code, + has_star: r.has_star, + created_at: r.created_at, + updated_at: r.updated_at, + is_orphaned: r.is_orphaned, + }) + .collect::>() + } + None => { + sqlx::query!( + r#" +SELECT + f.id as `id: u64`, + f.token as `token: FolderToken`, + f.name, + f.owner_user_token as `owner_user_token: UserToken`, + f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`, + f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`, + f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`, + f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`, + f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`, + f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`, + f.maybe_color_code, + f.has_star as `has_star: bool`, + f.created_at, + f.updated_at, + ( + f.maybe_parent_folder_token IS NOT NULL + AND (p.token IS NULL OR p.maybe_deleted_at IS NOT NULL) + ) as `is_orphaned: bool` +FROM folders f +LEFT JOIN folders p + ON p.token = f.maybe_parent_folder_token +WHERE f.owner_user_token = ? + AND f.maybe_deleted_at IS NULL +ORDER BY f.id DESC +LIMIT ? + "#, + args.owner_user_token.as_str(), + limit, + ) + .fetch_all(args.pool) + .await? + .into_iter() + .map(|r| FolderRow { + id: r.id, + token: r.token, + name: r.name, + owner_user_token: r.owner_user_token, + maybe_parent_folder_token: r.maybe_parent_folder_token, + maybe_last_media_file_token_1: r.maybe_last_media_file_token_1, + maybe_last_media_file_token_2: r.maybe_last_media_file_token_2, + maybe_last_media_file_token_3: r.maybe_last_media_file_token_3, + maybe_last_media_file_token_4: r.maybe_last_media_file_token_4, + maybe_cover_image_custom_media_token: r.maybe_cover_image_custom_media_token, + maybe_color_code: r.maybe_color_code, + has_star: r.has_star, + created_at: r.created_at, + updated_at: r.updated_at, + is_orphaned: r.is_orphaned, + }) + .collect::>() + } + }; + + Ok(rows) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/folder/mod.rs b/crates/schema/database/mysql_queries/src/queries/folders/folder/mod.rs new file mode 100644 index 0000000000..743ce3e44a --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/folder/mod.rs @@ -0,0 +1,8 @@ +pub mod folder_row; +pub mod get_folder_for_owner; +pub mod insert_folder; +pub mod list_folders_for_user; +pub mod soft_delete_folder; +pub mod update_folder_color_code; +pub mod update_folder_has_star; +pub mod update_folder_name; diff --git a/crates/schema/database/mysql_queries/src/queries/folders/folder/soft_delete_folder.rs b/crates/schema/database/mysql_queries/src/queries/folders/folder/soft_delete_folder.rs new file mode 100644 index 0000000000..675fea2927 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/folder/soft_delete_folder.rs @@ -0,0 +1,29 @@ +use sqlx::MySqlPool; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::users::UserToken; + +/// Mark a folder as deleted by stamping `maybe_deleted_at`. Children +/// retain their `maybe_parent_folder_token` and become "orphaned" — that +/// state is surfaced by the list query's `is_orphaned` flag. +pub async fn soft_delete_folder( + folder_token: &FolderToken, + owner_user_token: &UserToken, + pool: &MySqlPool, +) -> Result { + let result = sqlx::query!( + r#" +UPDATE folders +SET maybe_deleted_at = NOW() +WHERE token = ? + AND owner_user_token = ? + AND maybe_deleted_at IS NULL +LIMIT 1 + "#, + folder_token.as_str(), + owner_user_token.as_str(), + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/folder/update_folder_color_code.rs b/crates/schema/database/mysql_queries/src/queries/folders/folder/update_folder_color_code.rs new file mode 100644 index 0000000000..58f7a4e12c --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/folder/update_folder_color_code.rs @@ -0,0 +1,29 @@ +use sqlx::MySqlPool; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::users::UserToken; + +/// Set or clear (`None`) the color code on a folder. +pub async fn update_folder_color_code( + folder_token: &FolderToken, + owner_user_token: &UserToken, + maybe_color_code: Option<&str>, + pool: &MySqlPool, +) -> Result { + let result = sqlx::query!( + r#" +UPDATE folders +SET maybe_color_code = ? +WHERE token = ? + AND owner_user_token = ? + AND maybe_deleted_at IS NULL +LIMIT 1 + "#, + maybe_color_code, + folder_token.as_str(), + owner_user_token.as_str(), + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/folder/update_folder_has_star.rs b/crates/schema/database/mysql_queries/src/queries/folders/folder/update_folder_has_star.rs new file mode 100644 index 0000000000..215047447a --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/folder/update_folder_has_star.rs @@ -0,0 +1,28 @@ +use sqlx::MySqlPool; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::users::UserToken; + +pub async fn update_folder_has_star( + folder_token: &FolderToken, + owner_user_token: &UserToken, + has_star: bool, + pool: &MySqlPool, +) -> Result { + let result = sqlx::query!( + r#" +UPDATE folders +SET has_star = ? +WHERE token = ? + AND owner_user_token = ? + AND maybe_deleted_at IS NULL +LIMIT 1 + "#, + has_star, + folder_token.as_str(), + owner_user_token.as_str(), + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/folder/update_folder_name.rs b/crates/schema/database/mysql_queries/src/queries/folders/folder/update_folder_name.rs new file mode 100644 index 0000000000..2a0434c2d6 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/folder/update_folder_name.rs @@ -0,0 +1,31 @@ +use sqlx::MySqlPool; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::users::UserToken; + +/// Rename a folder. Scoped to owner so callers can't rename a folder they +/// don't own. Returns the number of rows updated (0 if no live folder +/// matched). +pub async fn update_folder_name( + folder_token: &FolderToken, + owner_user_token: &UserToken, + new_name: &str, + pool: &MySqlPool, +) -> Result { + let result = sqlx::query!( + r#" +UPDATE folders +SET name = ? +WHERE token = ? + AND owner_user_token = ? + AND maybe_deleted_at IS NULL +LIMIT 1 + "#, + new_name, + folder_token.as_str(), + owner_user_token.as_str(), + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/bulk_delete_folder_media_files.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/bulk_delete_folder_media_files.rs new file mode 100644 index 0000000000..7f3cbab8ee --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/bulk_delete_folder_media_files.rs @@ -0,0 +1,33 @@ +use sqlx::{MySql, MySqlPool, QueryBuilder}; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; + +/// Hard-delete membership rows for the given folder + media file tokens. +/// Idempotent — rows that don't exist are silently skipped. +/// +/// Returns the number of rows actually deleted. +pub async fn bulk_delete_folder_media_files( + folder_token: &FolderToken, + media_file_tokens: &[MediaFileToken], + pool: &MySqlPool, +) -> Result { + if media_file_tokens.is_empty() { + return Ok(0); + } + + let mut builder = QueryBuilder::::new( + "DELETE FROM folder_media_files WHERE folder_token = ", + ); + builder.push_bind(folder_token.as_str()); + builder.push(" AND media_file_token IN ("); + + let mut separated = builder.separated(", "); + for token in media_file_tokens { + separated.push_bind(token.as_str()); + } + separated.push_unseparated(")"); + + let result = builder.build().execute(pool).await?; + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/bulk_insert_folder_media_files.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/bulk_insert_folder_media_files.rs new file mode 100644 index 0000000000..7042a4a8bd --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/bulk_insert_folder_media_files.rs @@ -0,0 +1,30 @@ +use sqlx::{MySql, MySqlPool, QueryBuilder}; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; + +/// Insert membership rows for every (folder_token, media_file_token) in +/// the input. Uses `INSERT IGNORE` so already-present rows don't cause +/// duplicate-key errors — idempotent. +/// +/// Returns the number of rows actually inserted. +pub async fn bulk_insert_folder_media_files( + folder_token: &FolderToken, + media_file_tokens: &[MediaFileToken], + pool: &MySqlPool, +) -> Result { + if media_file_tokens.is_empty() { + return Ok(0); + } + + let mut builder = QueryBuilder::::new( + "INSERT IGNORE INTO folder_media_files (folder_token, media_file_token) ", + ); + builder.push_values(media_file_tokens, |mut b, media_file_token| { + b.push_bind(folder_token.as_str()) + .push_bind(media_file_token.as_str()); + }); + + let result = builder.build().execute(pool).await?; + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/filter_existing_media_file_tokens.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/filter_existing_media_file_tokens.rs new file mode 100644 index 0000000000..bc9ccd1af0 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/filter_existing_media_file_tokens.rs @@ -0,0 +1,32 @@ +use sqlx::{MySql, MySqlPool, QueryBuilder, Row}; + +use tokens::tokens::media_files::MediaFileToken; + +/// Given an input set of candidate media-file tokens, return only those +/// that currently exist and aren't soft-deleted (no user_deleted_at or +/// mod_deleted_at). +pub async fn filter_existing_media_file_tokens( + candidate_tokens: &[MediaFileToken], + pool: &MySqlPool, +) -> Result, sqlx::Error> { + if candidate_tokens.is_empty() { + return Ok(Vec::new()); + } + + let mut builder = QueryBuilder::::new( + "SELECT token FROM media_files WHERE user_deleted_at IS NULL \ + AND mod_deleted_at IS NULL AND token IN (", + ); + + let mut separated = builder.separated(", "); + for token in candidate_tokens { + separated.push_bind(token.as_str()); + } + separated.push_unseparated(")"); + + let rows = builder.build().fetch_all(pool).await?; + + Ok(rows.into_iter() + .map(|row| MediaFileToken::new(row.get::(0))) + .collect()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs new file mode 100644 index 0000000000..b729ce5be6 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs @@ -0,0 +1,151 @@ +use chrono::{DateTime, Utc}; +use sqlx::MySqlPool; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; + +#[derive(Debug, Clone)] +pub struct FolderMediaFileRow { + /// The `folder_media_files.id` of the membership row. Use this as the + /// cursor for the next page. + pub membership_id: u64, + + /// When the media file was added to the folder. + pub added_to_folder_at: DateTime, + + pub media_file_token: MediaFileToken, + pub media_type: String, + pub media_class: String, + pub maybe_mime_type: Option, + pub public_bucket_directory_hash: String, + pub maybe_public_bucket_prefix: Option, + pub maybe_public_bucket_extension: Option, + pub maybe_frame_width: Option, + pub maybe_frame_height: Option, + pub maybe_duration_millis: Option, + pub maybe_title: Option, +} + +pub struct ListFolderMediaFilesArgs<'a> { + pub folder_token: &'a FolderToken, + pub maybe_cursor_id: Option, + pub limit: u32, + pub pool: &'a MySqlPool, +} + +/// Paginated list of media files in the given folder. Joins `media_files` +/// and filters out rows that are soft-deleted on the media-file side +/// (`user_deleted_at` or `mod_deleted_at` set). Most-recently-added first. +/// +/// The caller is expected to have already authorized the folder access +/// (i.e. confirmed the folder is owned by the requesting user). +pub async fn list_folder_media_files( + args: ListFolderMediaFilesArgs<'_>, +) -> Result, sqlx::Error> { + let limit = args.limit as i64; + + let rows = match args.maybe_cursor_id { + Some(cursor_id) => { + sqlx::query!( + r#" +SELECT + fmf.id as `membership_id: u64`, + fmf.created_at as `added_to_folder_at: DateTime`, + mf.token as `media_file_token: MediaFileToken`, + mf.media_type, + mf.media_class, + mf.maybe_mime_type, + mf.public_bucket_directory_hash, + mf.maybe_public_bucket_prefix, + mf.maybe_public_bucket_extension, + mf.maybe_frame_width, + mf.maybe_frame_height, + mf.maybe_duration_millis, + mf.maybe_title +FROM folder_media_files fmf +JOIN media_files mf + ON mf.token = fmf.media_file_token +WHERE fmf.folder_token = ? + AND mf.user_deleted_at IS NULL + AND mf.mod_deleted_at IS NULL + AND fmf.id < ? +ORDER BY fmf.id DESC +LIMIT ? + "#, + args.folder_token.as_str(), + cursor_id, + limit, + ) + .fetch_all(args.pool) + .await? + .into_iter() + .map(|r| FolderMediaFileRow { + membership_id: r.membership_id, + added_to_folder_at: r.added_to_folder_at, + media_file_token: r.media_file_token, + media_type: r.media_type, + media_class: r.media_class, + maybe_mime_type: r.maybe_mime_type, + public_bucket_directory_hash: r.public_bucket_directory_hash, + maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, + maybe_public_bucket_extension: r.maybe_public_bucket_extension, + maybe_frame_width: r.maybe_frame_width, + maybe_frame_height: r.maybe_frame_height, + maybe_duration_millis: r.maybe_duration_millis, + maybe_title: r.maybe_title, + }) + .collect::>() + } + None => { + sqlx::query!( + r#" +SELECT + fmf.id as `membership_id: u64`, + fmf.created_at as `added_to_folder_at: DateTime`, + mf.token as `media_file_token: MediaFileToken`, + mf.media_type, + mf.media_class, + mf.maybe_mime_type, + mf.public_bucket_directory_hash, + mf.maybe_public_bucket_prefix, + mf.maybe_public_bucket_extension, + mf.maybe_frame_width, + mf.maybe_frame_height, + mf.maybe_duration_millis, + mf.maybe_title +FROM folder_media_files fmf +JOIN media_files mf + ON mf.token = fmf.media_file_token +WHERE fmf.folder_token = ? + AND mf.user_deleted_at IS NULL + AND mf.mod_deleted_at IS NULL +ORDER BY fmf.id DESC +LIMIT ? + "#, + args.folder_token.as_str(), + limit, + ) + .fetch_all(args.pool) + .await? + .into_iter() + .map(|r| FolderMediaFileRow { + membership_id: r.membership_id, + added_to_folder_at: r.added_to_folder_at, + media_file_token: r.media_file_token, + media_type: r.media_type, + media_class: r.media_class, + maybe_mime_type: r.maybe_mime_type, + public_bucket_directory_hash: r.public_bucket_directory_hash, + maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, + maybe_public_bucket_extension: r.maybe_public_bucket_extension, + maybe_frame_width: r.maybe_frame_width, + maybe_frame_height: r.maybe_frame_height, + maybe_duration_millis: r.maybe_duration_millis, + maybe_title: r.maybe_title, + }) + .collect::>() + } + }; + + Ok(rows) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/mod.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/mod.rs new file mode 100644 index 0000000000..66c52f360b --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/mod.rs @@ -0,0 +1,4 @@ +pub mod bulk_delete_folder_media_files; +pub mod bulk_insert_folder_media_files; +pub mod filter_existing_media_file_tokens; +pub mod list_folder_media_files; diff --git a/crates/schema/database/mysql_queries/src/queries/folders/mod.rs b/crates/schema/database/mysql_queries/src/queries/folders/mod.rs new file mode 100644 index 0000000000..0df0845fd3 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/mod.rs @@ -0,0 +1,3 @@ +pub mod folder; +pub mod media_files; +pub mod subfolder; diff --git a/crates/schema/database/mysql_queries/src/queries/folders/subfolder/bulk_clear_parent_folder.rs b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/bulk_clear_parent_folder.rs new file mode 100644 index 0000000000..72ac46105a --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/bulk_clear_parent_folder.rs @@ -0,0 +1,39 @@ +use sqlx::{MySql, MySqlPool, QueryBuilder}; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::users::UserToken; + +/// Clear `maybe_parent_folder_token` (set to NULL) on every owned, live +/// folder whose token is in `child_tokens` AND whose current parent is +/// the given `from_parent_token`. The parent guard makes the operation +/// safe to run idempotently — it won't accidentally unparent a folder +/// that's actually a child of a different parent. +/// +/// Returns the number of rows affected. +pub async fn bulk_clear_parent_folder( + child_tokens: &[FolderToken], + from_parent_token: &FolderToken, + owner_user_token: &UserToken, + pool: &MySqlPool, +) -> Result { + if child_tokens.is_empty() { + return Ok(0); + } + + let mut builder = QueryBuilder::::new( + "UPDATE folders SET maybe_parent_folder_token = NULL WHERE owner_user_token = ", + ); + builder.push_bind(owner_user_token.as_str()); + builder.push(" AND maybe_deleted_at IS NULL AND maybe_parent_folder_token = "); + builder.push_bind(from_parent_token.as_str()); + builder.push(" AND token IN ("); + + let mut separated = builder.separated(", "); + for token in child_tokens { + separated.push_bind(token.as_str()); + } + separated.push_unseparated(")"); + + let result = builder.build().execute(pool).await?; + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/subfolder/bulk_set_parent_folder.rs b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/bulk_set_parent_folder.rs new file mode 100644 index 0000000000..fcaf356e28 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/bulk_set_parent_folder.rs @@ -0,0 +1,43 @@ +use sqlx::{MySql, MySqlPool, QueryBuilder}; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::users::UserToken; + +/// Set `maybe_parent_folder_token = new_parent_token` on every owned, +/// live folder whose token is in `child_tokens` AND that isn't the parent +/// itself (a folder can't be its own parent). +/// +/// Returns the number of rows affected. +/// +/// Idempotent: if a row already has the desired parent, MySQL reports it +/// as not-changed but still as matched — `rows_affected()` reflects the +/// number of rows that actually changed. +pub async fn bulk_set_parent_folder( + child_tokens: &[FolderToken], + new_parent_token: &FolderToken, + owner_user_token: &UserToken, + pool: &MySqlPool, +) -> Result { + if child_tokens.is_empty() { + return Ok(0); + } + + let mut builder = QueryBuilder::::new( + "UPDATE folders SET maybe_parent_folder_token = ", + ); + builder.push_bind(new_parent_token.as_str()); + builder.push(" WHERE owner_user_token = "); + builder.push_bind(owner_user_token.as_str()); + builder.push(" AND maybe_deleted_at IS NULL AND token != "); + builder.push_bind(new_parent_token.as_str()); + builder.push(" AND token IN ("); + + let mut separated = builder.separated(", "); + for token in child_tokens { + separated.push_bind(token.as_str()); + } + separated.push_unseparated(")"); + + let result = builder.build().execute(pool).await?; + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/subfolder/filter_existing_owned_folder_tokens.rs b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/filter_existing_owned_folder_tokens.rs new file mode 100644 index 0000000000..1c82393c58 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/filter_existing_owned_folder_tokens.rs @@ -0,0 +1,38 @@ +use sqlx::{MySql, MySqlPool, QueryBuilder, Row}; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::users::UserToken; + +/// Given an input set of candidate folder tokens, return only those that +/// currently exist, are owned by the given user, and are not soft-deleted. +/// Order of the result is unspecified. +/// +/// Uses runtime `QueryBuilder` because the IN-list size is dynamic and the +/// macro form can't express that. +pub async fn filter_existing_owned_folder_tokens( + candidate_tokens: &[FolderToken], + owner_user_token: &UserToken, + pool: &MySqlPool, +) -> Result, sqlx::Error> { + if candidate_tokens.is_empty() { + return Ok(Vec::new()); + } + + let mut builder = QueryBuilder::::new( + "SELECT token FROM folders WHERE owner_user_token = ", + ); + builder.push_bind(owner_user_token.as_str()); + builder.push(" AND maybe_deleted_at IS NULL AND token IN ("); + + let mut separated = builder.separated(", "); + for token in candidate_tokens { + separated.push_bind(token.as_str()); + } + separated.push_unseparated(")"); + + let rows = builder.build().fetch_all(pool).await?; + + Ok(rows.into_iter() + .map(|row| FolderToken::new(row.get::(0))) + .collect()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/subfolder/list_subfolders.rs b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/list_subfolders.rs new file mode 100644 index 0000000000..88cd1d99df --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/list_subfolders.rs @@ -0,0 +1,138 @@ +use sqlx::MySqlPool; + +use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +use crate::queries::folders::folder::folder_row::FolderRow; + +pub struct ListSubfoldersArgs<'a> { + pub parent_folder_token: &'a FolderToken, + pub owner_user_token: &'a UserToken, + pub maybe_cursor_id: Option, + pub limit: u32, + pub pool: &'a MySqlPool, +} + +/// Paginated list of live folders whose `maybe_parent_folder_token` is +/// the given parent. Scoped to the owner to avoid leaking siblings of +/// folders owned by another user. +/// +/// The `is_orphaned` flag will be `false` for all results: by definition, +/// the parent matched here so it exists. (A leftover, soft-deleted parent +/// is still treated as "parent exists" — that case is rare and not the +/// caller's concern for this endpoint.) +pub async fn list_subfolders( + args: ListSubfoldersArgs<'_>, +) -> Result, sqlx::Error> { + let limit = args.limit as i64; + + let rows = match args.maybe_cursor_id { + Some(cursor_id) => { + sqlx::query!( + r#" +SELECT + f.id as `id: u64`, + f.token as `token: FolderToken`, + f.name, + f.owner_user_token as `owner_user_token: UserToken`, + f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`, + f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`, + f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`, + f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`, + f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`, + f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`, + f.maybe_color_code, + f.has_star as `has_star: bool`, + f.created_at, + f.updated_at +FROM folders f +WHERE f.maybe_parent_folder_token = ? + AND f.owner_user_token = ? + AND f.maybe_deleted_at IS NULL + AND f.id < ? +ORDER BY f.id DESC +LIMIT ? + "#, + args.parent_folder_token.as_str(), + args.owner_user_token.as_str(), + cursor_id, + limit, + ) + .fetch_all(args.pool) + .await? + .into_iter() + .map(|r| FolderRow { + id: r.id, + token: r.token, + name: r.name, + owner_user_token: r.owner_user_token, + maybe_parent_folder_token: r.maybe_parent_folder_token, + maybe_last_media_file_token_1: r.maybe_last_media_file_token_1, + maybe_last_media_file_token_2: r.maybe_last_media_file_token_2, + maybe_last_media_file_token_3: r.maybe_last_media_file_token_3, + maybe_last_media_file_token_4: r.maybe_last_media_file_token_4, + maybe_cover_image_custom_media_token: r.maybe_cover_image_custom_media_token, + maybe_color_code: r.maybe_color_code, + has_star: r.has_star, + created_at: r.created_at, + updated_at: r.updated_at, + is_orphaned: false, + }) + .collect::>() + } + None => { + sqlx::query!( + r#" +SELECT + f.id as `id: u64`, + f.token as `token: FolderToken`, + f.name, + f.owner_user_token as `owner_user_token: UserToken`, + f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`, + f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`, + f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`, + f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`, + f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`, + f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`, + f.maybe_color_code, + f.has_star as `has_star: bool`, + f.created_at, + f.updated_at +FROM folders f +WHERE f.maybe_parent_folder_token = ? + AND f.owner_user_token = ? + AND f.maybe_deleted_at IS NULL +ORDER BY f.id DESC +LIMIT ? + "#, + args.parent_folder_token.as_str(), + args.owner_user_token.as_str(), + limit, + ) + .fetch_all(args.pool) + .await? + .into_iter() + .map(|r| FolderRow { + id: r.id, + token: r.token, + name: r.name, + owner_user_token: r.owner_user_token, + maybe_parent_folder_token: r.maybe_parent_folder_token, + maybe_last_media_file_token_1: r.maybe_last_media_file_token_1, + maybe_last_media_file_token_2: r.maybe_last_media_file_token_2, + maybe_last_media_file_token_3: r.maybe_last_media_file_token_3, + maybe_last_media_file_token_4: r.maybe_last_media_file_token_4, + maybe_cover_image_custom_media_token: r.maybe_cover_image_custom_media_token, + maybe_color_code: r.maybe_color_code, + has_star: r.has_star, + created_at: r.created_at, + updated_at: r.updated_at, + is_orphaned: false, + }) + .collect::>() + } + }; + + Ok(rows) +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/subfolder/mod.rs b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/mod.rs new file mode 100644 index 0000000000..33a3ceff75 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/subfolder/mod.rs @@ -0,0 +1,4 @@ +pub mod bulk_clear_parent_folder; +pub mod bulk_set_parent_folder; +pub mod filter_existing_owned_folder_tokens; +pub mod list_subfolders; diff --git a/crates/schema/database/mysql_queries/src/queries/mod.rs b/crates/schema/database/mysql_queries/src/queries/mod.rs index 23df0f907c..de7e561443 100644 --- a/crates/schema/database/mysql_queries/src/queries/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/mod.rs @@ -13,6 +13,7 @@ pub mod database_time; pub mod email_sender_jobs; pub mod entity_stats; pub mod featured_items; +pub mod folders; pub mod generic_download; pub mod generic_inference; pub mod generic_synthetic_ids; diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 49dc8ed417..bf57fbbcb4 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -94,6 +94,25 @@ use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_ use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::*; use crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email_handler::*; use crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user_handler::*; + +// Folder endpoints +use crate::http_server::endpoints::media_files::folder::color_code_folder_handler::*; +use crate::http_server::endpoints::media_files::folder::create_folder_handler::*; +use crate::http_server::endpoints::media_files::folder::delete_folder_handler::*; +use crate::http_server::endpoints::media_files::folder::get_folder_handler::*; +use crate::http_server::endpoints::media_files::folder::list_folders_handler::*; +use crate::http_server::endpoints::media_files::folder::rename_folder_handler::*; +use crate::http_server::endpoints::media_files::folder::star_folder_handler::*; +use crate::http_server::endpoints::media_files::media_files::bulk_add_folder_media_files_handler::*; +use crate::http_server::endpoints::media_files::media_files::bulk_remove_folder_media_files_handler::*; +use crate::http_server::endpoints::media_files::media_files::list_folder_media_files_handler::*; +use crate::http_server::endpoints::media_files::subfolder::bulk_add_subfolders_handler::*; +use crate::http_server::endpoints::media_files::subfolder::bulk_remove_subfolders_handler::*; +use crate::http_server::endpoints::media_files::subfolder::list_subfolders_handler::*; +use artcraft_api_defs::folders::common::*; +use artcraft_api_defs::folders::folder::*; +use artcraft_api_defs::folders::media_files::*; +use artcraft_api_defs::folders::subfolder::*; use artcraft_api_defs::moderation::user_referrals::list_global_user_referrals::*; use artcraft_api_defs::moderation::user_referrals::list_user_referrals_for_user::*; use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::*; @@ -535,8 +554,23 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux crate::http_server::endpoints::moderation::user_referrals::moderator_list_global_user_referrals_handler::moderator_list_global_user_referrals_handler, crate::http_server::endpoints::moderation::user_referrals::moderator_list_user_referrals_for_user_handler::moderator_list_user_referrals_for_user_handler, crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler, - crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email::moderator_change_user_email_handler, - crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user::moderator_list_email_address_changes_for_user_handler, + crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email_handler::moderator_change_user_email_handler, + crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user_handler::moderator_list_email_address_changes_for_user_handler, + + // Folders + crate::http_server::endpoints::media_files::folder::create_folder_handler::create_folder_handler, + crate::http_server::endpoints::media_files::folder::list_folders_handler::list_folders_handler, + crate::http_server::endpoints::media_files::folder::get_folder_handler::get_folder_handler, + crate::http_server::endpoints::media_files::folder::rename_folder_handler::rename_folder_handler, + crate::http_server::endpoints::media_files::folder::star_folder_handler::star_folder_handler, + crate::http_server::endpoints::media_files::folder::color_code_folder_handler::color_code_folder_handler, + crate::http_server::endpoints::media_files::folder::delete_folder_handler::delete_folder_handler, + crate::http_server::endpoints::media_files::subfolder::list_subfolders_handler::list_subfolders_handler, + crate::http_server::endpoints::media_files::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler, + crate::http_server::endpoints::media_files::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler, + crate::http_server::endpoints::media_files::media_files::list_folder_media_files_handler::list_folder_media_files_handler, + crate::http_server::endpoints::media_files::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler, + crate::http_server::endpoints::media_files::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler, crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler, crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler, crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler, @@ -1205,6 +1239,37 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux // User Emails (Moderation) ModeratorListUserEmailChangesQueryParams, + + // Folders + FolderInfo, + FolderMediaFileInfo, + FolderPathInfo, + FolderMediaFilesPathInfo, + SubfolderPathInfo, + CreateFolderRequest, + CreateFolderSuccessResponse, + ListFoldersQueryParams, + ListFoldersSuccessResponse, + GetFolderSuccessResponse, + RenameFolderRequest, + RenameFolderSuccessResponse, + SetFolderStarRequest, + SetFolderStarSuccessResponse, + SetFolderColorCodeRequest, + SetFolderColorCodeSuccessResponse, + DeleteFolderSuccessResponse, + ListSubfoldersQueryParams, + ListSubfoldersSuccessResponse, + BulkAddSubfoldersRequest, + BulkAddSubfoldersSuccessResponse, + BulkRemoveSubfoldersRequest, + BulkRemoveSubfoldersSuccessResponse, + ListFolderMediaFilesQueryParams, + ListFolderMediaFilesSuccessResponse, + BulkAddFolderMediaFilesRequest, + BulkAddFolderMediaFilesSuccessResponse, + BulkRemoveFolderMediaFilesRequest, + BulkRemoveFolderMediaFilesSuccessResponse, )) )] pub struct ApiDoc; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/color_code_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/color_code_folder_handler.rs new file mode 100644 index 0000000000..d2d624bed0 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/color_code_folder_handler.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::folder::{ + FolderPathInfo, SetFolderColorCodeRequest, SetFolderColorCodeSuccessResponse, +}; +use mysql_queries::queries::folders::folder::update_folder_color_code::update_folder_color_code; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +const MAX_COLOR_CODE_LEN: usize = 16; + +#[utoipa::path( + put, + tag = "Folders", + path = "/v1/folders/folder/{folder_token}/color_code", + params(("folder_token" = String, description = "Folder token")), + request_body = SetFolderColorCodeRequest, + responses( + (status = 200, body = SetFolderColorCodeSuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn color_code_folder_handler( + http_request: HttpRequest, + path: Path, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + // Normalize: trim, treat empty after trim as `None`. This lets clients + // clear by passing `""` or by omitting the field entirely. + let trimmed: Option = request.maybe_color_code.as_ref().and_then(|s| { + let t = s.trim(); + if t.is_empty() { None } else { Some(t.to_string()) } + }); + + if let Some(code) = trimmed.as_deref() { + if code.len() > MAX_COLOR_CODE_LEN { + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("color_code too long (max {} chars)", MAX_COLOR_CODE_LEN), + )); + } + } + + let folder_token = FolderToken::new_from_str(path.folder_token.trim()); + + let rows_affected = update_folder_color_code( + &folder_token, + &user_session.user_token, + trimmed.as_deref(), + &server_state.mysql_pool, + ).await.map_err(|err| { + warn!("update_folder_color_code failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + if rows_affected == 0 { + return Err(CommonWebError::NotFound); + } + + Ok(Json(SetFolderColorCodeSuccessResponse { success: true })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/create_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/create_folder_handler.rs new file mode 100644 index 0000000000..0e0a966a33 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/create_folder_handler.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::common::FolderInfo; +use artcraft_api_defs::folders::folder::{CreateFolderRequest, CreateFolderSuccessResponse}; +use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner; +use mysql_queries::queries::folders::folder::insert_folder::{insert_folder, InsertFolderArgs}; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::media_files::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +const MAX_NAME_LEN: usize = 255; + +/// Create a new folder owned by the logged-in user. +#[utoipa::path( + post, + tag = "Folders", + path = "/v1/folders/create", + request_body = CreateFolderRequest, + responses( + (status = 200, body = CreateFolderSuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn create_folder_handler( + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + let name = request.name.trim().to_string(); + if name.is_empty() { + return Err(CommonWebError::BadInputWithSimpleMessage("name is empty".to_string())); + } + if name.len() > MAX_NAME_LEN { + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("name too long (max {} chars)", MAX_NAME_LEN), + )); + } + + // If a parent was supplied, require it to exist + be owned by the caller. + if let Some(parent_token) = &request.maybe_parent_folder_token { + let parent = get_folder_for_owner(parent_token, &user_session.user_token, &server_state.mysql_pool) + .await + .map_err(|err| { + warn!("Parent folder lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + if parent.is_none() { + return Err(CommonWebError::BadInputWithSimpleMessage( + "parent folder does not exist".to_string(), + )); + } + } + + let token = FolderToken::generate(); + + insert_folder(InsertFolderArgs { + token: &token, + name: &name, + owner_user_token: &user_session.user_token, + maybe_parent_folder_token: request.maybe_parent_folder_token.as_ref(), + maybe_color_code: request.maybe_color_code.as_deref(), + }, &server_state.mysql_pool).await.map_err(|err| { + warn!("insert_folder failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + // Re-fetch so we return the canonical FolderInfo including is_orphaned. + let row = get_folder_for_owner(&token, &user_session.user_token, &server_state.mysql_pool) + .await + .map_err(|err| { + warn!("Post-insert folder fetch failed: {:?}", err); + CommonWebError::from_error(err) + })? + .ok_or_else(|| CommonWebError::server_error_with_message("folder vanished after insert"))?; + + let folder: FolderInfo = folder_row_to_info(row); + + Ok(Json(CreateFolderSuccessResponse { + success: true, + folder, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/delete_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/delete_folder_handler.rs new file mode 100644 index 0000000000..c5d170eee9 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/delete_folder_handler.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::folder::{DeleteFolderSuccessResponse, FolderPathInfo}; +use mysql_queries::queries::folders::folder::soft_delete_folder::soft_delete_folder; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +/// Soft-delete a folder. Children retain their parent pointer and become +/// "orphaned" — surfaced by the list query's `is_orphaned` flag. +#[utoipa::path( + delete, + tag = "Folders", + path = "/v1/folders/folder/{folder_token}", + params(("folder_token" = String, description = "Folder token")), + responses( + (status = 200, body = DeleteFolderSuccessResponse), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn delete_folder_handler( + http_request: HttpRequest, + path: Path, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + let folder_token = FolderToken::new_from_str(path.folder_token.trim()); + + let rows_affected = soft_delete_folder( + &folder_token, + &user_session.user_token, + &server_state.mysql_pool, + ).await.map_err(|err| { + warn!("soft_delete_folder failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + if rows_affected == 0 { + return Err(CommonWebError::NotFound); + } + + Ok(Json(DeleteFolderSuccessResponse { success: true })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/folder_info_conversion.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/folder_info_conversion.rs new file mode 100644 index 0000000000..c78579e677 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/folder_info_conversion.rs @@ -0,0 +1,39 @@ +use artcraft_api_defs::folders::common::{FolderInfo, FolderMediaFileInfo}; +use mysql_queries::queries::folders::folder::folder_row::FolderRow; +use mysql_queries::queries::folders::media_files::list_folder_media_files::FolderMediaFileRow; + +pub fn folder_row_to_info(row: FolderRow) -> FolderInfo { + FolderInfo { + token: row.token, + name: row.name, + owner_user_token: row.owner_user_token, + maybe_parent_folder_token: row.maybe_parent_folder_token, + maybe_last_media_file_token_1: row.maybe_last_media_file_token_1, + maybe_last_media_file_token_2: row.maybe_last_media_file_token_2, + maybe_last_media_file_token_3: row.maybe_last_media_file_token_3, + maybe_last_media_file_token_4: row.maybe_last_media_file_token_4, + maybe_cover_image_custom_media_token: row.maybe_cover_image_custom_media_token, + maybe_color_code: row.maybe_color_code, + has_star: row.has_star, + created_at: row.created_at, + updated_at: row.updated_at, + is_orphaned: row.is_orphaned, + } +} + +pub fn folder_media_file_row_to_info(row: FolderMediaFileRow) -> FolderMediaFileInfo { + FolderMediaFileInfo { + media_file_token: row.media_file_token, + added_to_folder_at: row.added_to_folder_at, + media_type: row.media_type, + media_class: row.media_class, + maybe_mime_type: row.maybe_mime_type, + public_bucket_directory_hash: row.public_bucket_directory_hash, + maybe_public_bucket_prefix: row.maybe_public_bucket_prefix, + maybe_public_bucket_extension: row.maybe_public_bucket_extension, + maybe_frame_width: row.maybe_frame_width, + maybe_frame_height: row.maybe_frame_height, + maybe_duration_millis: row.maybe_duration_millis, + maybe_title: row.maybe_title, + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/get_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/get_folder_handler.rs new file mode 100644 index 0000000000..6ed11a5924 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/get_folder_handler.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::folder::{FolderPathInfo, GetFolderSuccessResponse}; +use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::media_files::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +/// Fetch a single folder owned by the logged-in user. +#[utoipa::path( + get, + tag = "Folders", + path = "/v1/folders/folder/{folder_token}", + params(("folder_token" = String, description = "Folder token")), + responses( + (status = 200, body = GetFolderSuccessResponse), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn get_folder_handler( + http_request: HttpRequest, + path: Path, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + let folder_token = FolderToken::new_from_str(path.folder_token.trim()); + + let row = get_folder_for_owner(&folder_token, &user_session.user_token, &server_state.mysql_pool) + .await + .map_err(|err| { + warn!("get_folder_for_owner failed: {:?}", err); + CommonWebError::from_error(err) + })? + .ok_or(CommonWebError::NotFound)?; + + Ok(Json(GetFolderSuccessResponse { + success: true, + folder: folder_row_to_info(row), + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/list_folders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/list_folders_handler.rs new file mode 100644 index 0000000000..58e859006d --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/list_folders_handler.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Query}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::folder::{ + ListFoldersQueryParams, ListFoldersSuccessResponse, +}; +use mysql_queries::queries::folders::folder::list_folders_for_user::{ + list_folders_for_user, ListFoldersForUserArgs, +}; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::media_files::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +const CURSOR_NAME: &str = "folders_all"; +const DEFAULT_LIMIT: u32 = 100; +const MAX_LIMIT: u32 = 1000; + +/// List all live folders owned by the logged-in user. Newest first. +/// Includes the derived `is_orphaned` flag. +#[utoipa::path( + get, + tag = "Folders", + path = "/v1/folders/list_all", + params(ListFoldersQueryParams), + responses( + (status = 200, body = ListFoldersSuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn list_folders_handler( + http_request: HttpRequest, + query: Query, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + + let maybe_cursor_id = match &query.cursor { + None => None, + Some(cursor_str) => { + let decoded = server_state.opaque_cursors + .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) + .map_err(|err| { + warn!("Failed to decode cursor: {:?}", err); + CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string()) + })?; + decoded.last_id + } + }; + + let rows = list_folders_for_user(ListFoldersForUserArgs { + owner_user_token: &user_session.user_token, + maybe_cursor_id, + limit, + pool: &server_state.mysql_pool, + }).await.map_err(|err| { + warn!("list_folders_for_user failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + let maybe_cursor = rows.last().map(|last| { + server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id) + }).transpose().map_err(|err| { + warn!("Failed to encode cursor: {:?}", err); + CommonWebError::server_error_with_message("Failed to encode cursor") + })?; + + let folders = rows.into_iter().map(folder_row_to_info).collect(); + + Ok(Json(ListFoldersSuccessResponse { + success: true, + folders, + maybe_cursor, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/mod.rs new file mode 100644 index 0000000000..0aecb7c341 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/mod.rs @@ -0,0 +1,8 @@ +pub mod color_code_folder_handler; +pub mod create_folder_handler; +pub mod delete_folder_handler; +pub mod folder_info_conversion; +pub mod get_folder_handler; +pub mod list_folders_handler; +pub mod rename_folder_handler; +pub mod star_folder_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/rename_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/rename_folder_handler.rs new file mode 100644 index 0000000000..c32bba9e10 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/rename_folder_handler.rs @@ -0,0 +1,78 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::folder::{ + FolderPathInfo, RenameFolderRequest, RenameFolderSuccessResponse, +}; +use mysql_queries::queries::folders::folder::update_folder_name::update_folder_name; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +const MAX_NAME_LEN: usize = 255; + +#[utoipa::path( + put, + tag = "Folders", + path = "/v1/folders/folder/{folder_token}/rename", + params(("folder_token" = String, description = "Folder token")), + request_body = RenameFolderRequest, + responses( + (status = 200, body = RenameFolderSuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn rename_folder_handler( + http_request: HttpRequest, + path: Path, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + let new_name = request.new_name.trim().to_string(); + if new_name.is_empty() { + return Err(CommonWebError::BadInputWithSimpleMessage("name is empty".to_string())); + } + if new_name.len() > MAX_NAME_LEN { + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("name too long (max {} chars)", MAX_NAME_LEN), + )); + } + + let folder_token = FolderToken::new_from_str(path.folder_token.trim()); + + let rows_affected = update_folder_name( + &folder_token, + &user_session.user_token, + &new_name, + &server_state.mysql_pool, + ).await.map_err(|err| { + warn!("update_folder_name failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + if rows_affected == 0 { + // No live row matched the (token, owner) pair — either it doesn't + // exist, is owned by someone else, or is soft-deleted. Don't leak + // which. + return Err(CommonWebError::NotFound); + } + + Ok(Json(RenameFolderSuccessResponse { success: true })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/star_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/star_folder_handler.rs new file mode 100644 index 0000000000..3a5f9de0aa --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/folder/star_folder_handler.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::folder::{ + FolderPathInfo, SetFolderStarRequest, SetFolderStarSuccessResponse, +}; +use mysql_queries::queries::folders::folder::update_folder_has_star::update_folder_has_star; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +#[utoipa::path( + put, + tag = "Folders", + path = "/v1/folders/folder/{folder_token}/star", + params(("folder_token" = String, description = "Folder token")), + request_body = SetFolderStarRequest, + responses( + (status = 200, body = SetFolderStarSuccessResponse), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn star_folder_handler( + http_request: HttpRequest, + path: Path, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + let folder_token = FolderToken::new_from_str(path.folder_token.trim()); + + let rows_affected = update_folder_has_star( + &folder_token, + &user_session.user_token, + request.has_star, + &server_state.mysql_pool, + ).await.map_err(|err| { + warn!("update_folder_has_star failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + if rows_affected == 0 { + return Err(CommonWebError::NotFound); + } + + Ok(Json(SetFolderStarSuccessResponse { success: true })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/bulk_add_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/bulk_add_folder_media_files_handler.rs new file mode 100644 index 0000000000..68c2bfb506 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/bulk_add_folder_media_files_handler.rs @@ -0,0 +1,98 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::media_files::{ + BulkAddFolderMediaFilesRequest, BulkAddFolderMediaFilesSuccessResponse, + FolderMediaFilesPathInfo, +}; +use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner; +use mysql_queries::queries::folders::media_files::bulk_insert_folder_media_files::bulk_insert_folder_media_files; +use mysql_queries::queries::folders::media_files::filter_existing_media_file_tokens::filter_existing_media_file_tokens; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +const MAX_BULK: usize = 500; + +/// Bulk-add media files to a folder. Idempotent via INSERT IGNORE on the +/// composite primary key. Media-file tokens that don't exist or are +/// soft-deleted are silently skipped; the response lists the accepted +/// tokens. +#[utoipa::path( + put, + tag = "Folders", + path = "/v1/folders/media_files/{folder_token}/bulk_add", + params(("folder_token" = String, description = "Folder token")), + request_body = BulkAddFolderMediaFilesRequest, + responses( + (status = 200, body = BulkAddFolderMediaFilesSuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn bulk_add_folder_media_files_handler( + http_request: HttpRequest, + path: Path, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + if request.media_file_tokens.len() > MAX_BULK { + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("too many media files in one request (max {})", MAX_BULK), + )); + } + + let folder_token = FolderToken::new_from_str(path.folder_token.trim()); + + // Confirm the folder exists + is owned. + let folder = get_folder_for_owner(&folder_token, &user_session.user_token, &server_state.mysql_pool) + .await + .map_err(|err| { + warn!("Folder lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + if folder.is_none() { + return Err(CommonWebError::NotFound); + } + + // Filter to media files that actually exist + aren't deleted. + let accepted = filter_existing_media_file_tokens( + &request.media_file_tokens, + &server_state.mysql_pool, + ).await.map_err(|err| { + warn!("filter_existing_media_file_tokens failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + if !accepted.is_empty() { + bulk_insert_folder_media_files( + &folder_token, + &accepted, + &server_state.mysql_pool, + ).await.map_err(|err| { + warn!("bulk_insert_folder_media_files failed: {:?}", err); + CommonWebError::from_error(err) + })?; + } + + Ok(Json(BulkAddFolderMediaFilesSuccessResponse { + success: true, + accepted_media_file_tokens: accepted, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/bulk_remove_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/bulk_remove_folder_media_files_handler.rs new file mode 100644 index 0000000000..d024279193 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/bulk_remove_folder_media_files_handler.rs @@ -0,0 +1,86 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::media_files::{ + BulkRemoveFolderMediaFilesRequest, BulkRemoveFolderMediaFilesSuccessResponse, + FolderMediaFilesPathInfo, +}; +use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner; +use mysql_queries::queries::folders::media_files::bulk_delete_folder_media_files::bulk_delete_folder_media_files; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +const MAX_BULK: usize = 500; + +/// Bulk-remove media files from a folder. Hard-deletes the membership +/// rows; idempotent (rows that don't exist are silently skipped). +#[utoipa::path( + put, + tag = "Folders", + path = "/v1/folders/media_files/{folder_token}/bulk_remove", + params(("folder_token" = String, description = "Folder token")), + request_body = BulkRemoveFolderMediaFilesRequest, + responses( + (status = 200, body = BulkRemoveFolderMediaFilesSuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn bulk_remove_folder_media_files_handler( + http_request: HttpRequest, + path: Path, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + if request.media_file_tokens.len() > MAX_BULK { + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("too many media files in one request (max {})", MAX_BULK), + )); + } + + let folder_token = FolderToken::new_from_str(path.folder_token.trim()); + + // Confirm the folder exists + is owned. We don't strictly need this for + // correctness (the DELETE's folder_token guard suffices), but it keeps + // the 404 vs 200 semantics consistent with the rest of the API. + let folder = get_folder_for_owner(&folder_token, &user_session.user_token, &server_state.mysql_pool) + .await + .map_err(|err| { + warn!("Folder lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + if folder.is_none() { + return Err(CommonWebError::NotFound); + } + + let removed_count = bulk_delete_folder_media_files( + &folder_token, + &request.media_file_tokens, + &server_state.mysql_pool, + ).await.map_err(|err| { + warn!("bulk_delete_folder_media_files failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + Ok(Json(BulkRemoveFolderMediaFilesSuccessResponse { + success: true, + removed_count, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/list_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/list_folder_media_files_handler.rs new file mode 100644 index 0000000000..2b7b3f1ed6 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/list_folder_media_files_handler.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Path, Query}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::media_files::{ + FolderMediaFilesPathInfo, ListFolderMediaFilesQueryParams, ListFolderMediaFilesSuccessResponse, +}; +use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner; +use mysql_queries::queries::folders::media_files::list_folder_media_files::{ + list_folder_media_files, ListFolderMediaFilesArgs, +}; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::media_files::folder::folder_info_conversion::folder_media_file_row_to_info; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +const CURSOR_NAME: &str = "folder_mf"; +const DEFAULT_LIMIT: u32 = 100; +const MAX_LIMIT: u32 = 1000; + +#[utoipa::path( + get, + tag = "Folders", + path = "/v1/folders/media_files/{folder_token}", + params( + ("folder_token" = String, description = "Folder token"), + ListFolderMediaFilesQueryParams, + ), + responses( + (status = 200, body = ListFolderMediaFilesSuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn list_folder_media_files_handler( + http_request: HttpRequest, + path: Path, + query: Query, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + let folder_token = FolderToken::new_from_str(path.folder_token.trim()); + + // Confirm the folder exists + is owned. + let folder = get_folder_for_owner(&folder_token, &user_session.user_token, &server_state.mysql_pool) + .await + .map_err(|err| { + warn!("Folder lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + if folder.is_none() { + return Err(CommonWebError::NotFound); + } + + let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + + let maybe_cursor_id = match &query.cursor { + None => None, + Some(cursor_str) => { + let decoded = server_state.opaque_cursors + .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) + .map_err(|err| { + warn!("Failed to decode cursor: {:?}", err); + CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string()) + })?; + decoded.last_id + } + }; + + let rows = list_folder_media_files(ListFolderMediaFilesArgs { + folder_token: &folder_token, + maybe_cursor_id, + limit, + pool: &server_state.mysql_pool, + }).await.map_err(|err| { + warn!("list_folder_media_files failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + let maybe_cursor = rows.last().map(|last| { + server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.membership_id) + }).transpose().map_err(|err| { + warn!("Failed to encode cursor: {:?}", err); + CommonWebError::server_error_with_message("Failed to encode cursor") + })?; + + let media_files = rows.into_iter().map(folder_media_file_row_to_info).collect(); + + Ok(Json(ListFolderMediaFilesSuccessResponse { + success: true, + media_files, + maybe_cursor, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/mod.rs new file mode 100644 index 0000000000..9df9d7d93d --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/media_files/mod.rs @@ -0,0 +1,3 @@ +pub mod bulk_add_folder_media_files_handler; +pub mod bulk_remove_folder_media_files_handler; +pub mod list_folder_media_files_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs index 818b03da01..8920d33891 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs @@ -1,9 +1,12 @@ pub mod common_responses; pub mod delete; pub mod edit; +pub mod folder; pub mod get; pub mod helpers; pub mod list; +pub mod media_files; pub mod search; +pub mod subfolder; pub mod upload; pub mod upsert_upload; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/bulk_add_subfolders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/bulk_add_subfolders_handler.rs new file mode 100644 index 0000000000..0ebf6dc6c1 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/bulk_add_subfolders_handler.rs @@ -0,0 +1,101 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::subfolder::{ + BulkAddSubfoldersRequest, BulkAddSubfoldersSuccessResponse, SubfolderPathInfo, +}; +use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner; +use mysql_queries::queries::folders::subfolder::bulk_set_parent_folder::bulk_set_parent_folder; +use mysql_queries::queries::folders::subfolder::filter_existing_owned_folder_tokens::filter_existing_owned_folder_tokens; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +const MAX_BULK: usize = 500; + +/// Bulk-reparent folders under the URL `folder_token`. Idempotent: tokens +/// that don't exist or are already parented to this folder are silently +/// accepted; the response lists the tokens that "stick". +#[utoipa::path( + put, + tag = "Folders", + path = "/v1/folders/subfolders/{folder_token}/bulk_add", + params(("folder_token" = String, description = "Parent folder token")), + request_body = BulkAddSubfoldersRequest, + responses( + (status = 200, body = BulkAddSubfoldersSuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn bulk_add_subfolders_handler( + http_request: HttpRequest, + path: Path, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + if request.subfolder_tokens.len() > MAX_BULK { + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("too many subfolders in one request (max {})", MAX_BULK), + )); + } + + let parent_token = FolderToken::new_from_str(path.folder_token.trim()); + + // Confirm the parent exists + is owned. + let parent = get_folder_for_owner(&parent_token, &user_session.user_token, &server_state.mysql_pool) + .await + .map_err(|err| { + warn!("Parent folder lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + if parent.is_none() { + return Err(CommonWebError::NotFound); + } + + // Filter input to tokens that actually exist, are owned, and aren't deleted. + let accepted = filter_existing_owned_folder_tokens( + &request.subfolder_tokens, + &user_session.user_token, + &server_state.mysql_pool, + ).await.map_err(|err| { + warn!("filter_existing_owned_folder_tokens failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + if !accepted.is_empty() { + bulk_set_parent_folder( + &accepted, + &parent_token, + &user_session.user_token, + &server_state.mysql_pool, + ).await.map_err(|err| { + warn!("bulk_set_parent_folder failed: {:?}", err); + CommonWebError::from_error(err) + })?; + } + + // Note: `accepted` may include the parent itself; the SQL guard excludes + // it from the UPDATE. We still report it as "accepted" for client + // simplicity — no-op tokens are normal in idempotent bulk endpoints. + Ok(Json(BulkAddSubfoldersSuccessResponse { + success: true, + accepted_subfolder_tokens: accepted, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/bulk_remove_subfolders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/bulk_remove_subfolders_handler.rs new file mode 100644 index 0000000000..b89ec4e659 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/bulk_remove_subfolders_handler.rs @@ -0,0 +1,74 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::subfolder::{ + BulkRemoveSubfoldersRequest, BulkRemoveSubfoldersSuccessResponse, SubfolderPathInfo, +}; +use mysql_queries::queries::folders::subfolder::bulk_clear_parent_folder::bulk_clear_parent_folder; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +const MAX_BULK: usize = 500; + +/// Bulk-unparent folders from the URL `folder_token`. Idempotent: rows +/// that aren't currently parented to this folder are silently skipped. +#[utoipa::path( + put, + tag = "Folders", + path = "/v1/folders/subfolders/{folder_token}/bulk_remove", + params(("folder_token" = String, description = "Parent folder token")), + request_body = BulkRemoveSubfoldersRequest, + responses( + (status = 200, body = BulkRemoveSubfoldersSuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn bulk_remove_subfolders_handler( + http_request: HttpRequest, + path: Path, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + if request.subfolder_tokens.len() > MAX_BULK { + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("too many subfolders in one request (max {})", MAX_BULK), + )); + } + + let from_parent_token = FolderToken::new_from_str(path.folder_token.trim()); + + // No parent existence check — the UPDATE's WHERE clause already gates + // on owner_user_token AND current parent, so this is naturally a no-op + // for rows we shouldn't touch. + let removed_count = bulk_clear_parent_folder( + &request.subfolder_tokens, + &from_parent_token, + &user_session.user_token, + &server_state.mysql_pool, + ).await.map_err(|err| { + warn!("bulk_clear_parent_folder failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + Ok(Json(BulkRemoveSubfoldersSuccessResponse { + success: true, + removed_count, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/list_subfolders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/list_subfolders_handler.rs new file mode 100644 index 0000000000..25fa4306c4 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/list_subfolders_handler.rs @@ -0,0 +1,109 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Path, Query}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::folders::subfolder::{ + ListSubfoldersQueryParams, ListSubfoldersSuccessResponse, SubfolderPathInfo, +}; +use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner; +use mysql_queries::queries::folders::subfolder::list_subfolders::{ + list_subfolders, ListSubfoldersArgs, +}; +use tokens::tokens::folders::FolderToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::media_files::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; + +const CURSOR_NAME: &str = "subfolders"; +const DEFAULT_LIMIT: u32 = 100; +const MAX_LIMIT: u32 = 1000; + +#[utoipa::path( + get, + tag = "Folders", + path = "/v1/folders/subfolders/{folder_token}", + params( + ("folder_token" = String, description = "Parent folder token"), + ListSubfoldersQueryParams, + ), + responses( + (status = 200, body = ListSubfoldersSuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn list_subfolders_handler( + http_request: HttpRequest, + path: Path, + query: Query, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session_using_connection( + &http_request, &server_state.session_checker, &mut conn, + ).await.map_err(|_| CommonWebError::NotAuthorized)?; + + let parent_token = FolderToken::new_from_str(path.folder_token.trim()); + + // Confirm the parent exists + is owned by the caller before listing. + let parent = get_folder_for_owner(&parent_token, &user_session.user_token, &server_state.mysql_pool) + .await + .map_err(|err| { + warn!("Parent folder lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + if parent.is_none() { + return Err(CommonWebError::NotFound); + } + + let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + + let maybe_cursor_id = match &query.cursor { + None => None, + Some(cursor_str) => { + let decoded = server_state.opaque_cursors + .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) + .map_err(|err| { + warn!("Failed to decode cursor: {:?}", err); + CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string()) + })?; + decoded.last_id + } + }; + + let rows = list_subfolders(ListSubfoldersArgs { + parent_folder_token: &parent_token, + owner_user_token: &user_session.user_token, + maybe_cursor_id, + limit, + pool: &server_state.mysql_pool, + }).await.map_err(|err| { + warn!("list_subfolders failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + let maybe_cursor = rows.last().map(|last| { + server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id) + }).transpose().map_err(|err| { + warn!("Failed to encode cursor: {:?}", err); + CommonWebError::server_error_with_message("Failed to encode cursor") + })?; + + let subfolders = rows.into_iter().map(folder_row_to_info).collect(); + + Ok(Json(ListSubfoldersSuccessResponse { + success: true, + subfolders, + maybe_cursor, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/mod.rs new file mode 100644 index 0000000000..6042e66cf6 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/subfolder/mod.rs @@ -0,0 +1,3 @@ +pub mod bulk_add_subfolders_handler; +pub mod bulk_remove_subfolders_handler; +pub mod list_subfolders_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs index 80b38ec548..8983a3842d 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs @@ -5,6 +5,7 @@ use crate::http_server::routes::application_routes::comments_routes::add_comment use crate::http_server::routes::application_routes::credits_routes::add_credits_routes; use crate::http_server::routes::application_routes::dev_routes::add_dev_routes; use crate::http_server::routes::application_routes::featured_item_routes::add_featured_item_routes; +use crate::http_server::routes::application_routes::folders_routes::add_folders_routes; use crate::http_server::routes::application_routes::generate_routes::add_generate_routes; use crate::http_server::routes::application_routes::job_routes::add_job_routes; use crate::http_server::routes::application_routes::media_files_routes::add_media_file_routes; @@ -55,6 +56,7 @@ where // Media files routes app = add_media_file_routes(app); // /v1/media_files/... + app = add_folders_routes(app); // /v1/folders/... app = add_featured_item_routes(app); // /v1/featured_item/... app = add_prompts_routes(app); // /v1/prompts/... diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/folders_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/folders_routes.rs new file mode 100644 index 0000000000..f3992252bf --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/folders_routes.rs @@ -0,0 +1,107 @@ +use actix_http::body::MessageBody; +use actix_service::ServiceFactory; +use actix_web::dev::{ServiceRequest, ServiceResponse}; +use actix_web::{web, App, Error, HttpResponse}; + +use crate::http_server::endpoints::media_files::folder::color_code_folder_handler::color_code_folder_handler; +use crate::http_server::endpoints::media_files::folder::create_folder_handler::create_folder_handler; +use crate::http_server::endpoints::media_files::folder::delete_folder_handler::delete_folder_handler; +use crate::http_server::endpoints::media_files::folder::get_folder_handler::get_folder_handler; +use crate::http_server::endpoints::media_files::folder::list_folders_handler::list_folders_handler; +use crate::http_server::endpoints::media_files::folder::rename_folder_handler::rename_folder_handler; +use crate::http_server::endpoints::media_files::folder::star_folder_handler::star_folder_handler; +use crate::http_server::endpoints::media_files::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler; +use crate::http_server::endpoints::media_files::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler; +use crate::http_server::endpoints::media_files::media_files::list_folder_media_files_handler::list_folder_media_files_handler; +use crate::http_server::endpoints::media_files::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler; +use crate::http_server::endpoints::media_files::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler; +use crate::http_server::endpoints::media_files::subfolder::list_subfolders_handler::list_subfolders_handler; + +pub fn add_folders_routes(app: App) -> App +where + B: MessageBody, + T: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = Error, + InitError = (), + >, +{ + app.service( + web::scope("/v1/folders") + // Folder CRUD + .service( + web::resource("/create") + .route(web::post().to(create_folder_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) + .service( + web::resource("/list_all") + .route(web::get().to(list_folders_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) + .service( + web::scope("/folder") + .service( + web::resource("/{folder_token}") + .route(web::get().to(get_folder_handler)) + .route(web::delete().to(delete_folder_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) + .service( + web::resource("/{folder_token}/rename") + .route(web::put().to(rename_folder_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) + .service( + web::resource("/{folder_token}/star") + .route(web::put().to(star_folder_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) + .service( + web::resource("/{folder_token}/color_code") + .route(web::put().to(color_code_folder_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ), + ) + // Subfolder membership + .service( + web::scope("/subfolders") + .service( + web::resource("/{folder_token}") + .route(web::get().to(list_subfolders_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) + .service( + web::resource("/{folder_token}/bulk_add") + .route(web::put().to(bulk_add_subfolders_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) + .service( + web::resource("/{folder_token}/bulk_remove") + .route(web::put().to(bulk_remove_subfolders_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ), + ) + // Media-file membership + .service( + web::scope("/media_files") + .service( + web::resource("/{folder_token}") + .route(web::get().to(list_folder_media_files_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) + .service( + web::resource("/{folder_token}/bulk_add") + .route(web::put().to(bulk_add_folder_media_files_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) + .service( + web::resource("/{folder_token}/bulk_remove") + .route(web::put().to(bulk_remove_folder_media_files_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ), + ), + ) +} diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs index 22a8fc51e2..d22ececf7e 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs @@ -5,6 +5,7 @@ mod comments_routes; mod credits_routes; mod dev_routes; mod featured_item_routes; +mod folders_routes; mod generate_routes; mod job_routes; mod media_files_routes;