Compare commits

...
Author SHA1 Message Date
Brandon Thomas 09c30bf4a9 codegen queries 2026-06-08 12:06:42 -04:00
Brandon Thomas c98c6b708e forgot 2026-06-08 12:06:22 -04:00
Brandon Thomas f674aec46c recursive check 2026-06-08 11:02:10 -04:00
Brandon Thomas 3f2086d85b prevent self-parenting 2026-06-08 10:58:43 -04:00
Brandon Thomas 2844314776 cover images 2026-06-08 10:52:44 -04:00
Brandon Thomas fd42d33bdd set cover image 2026-06-08 10:38:40 -04:00
Brandon Thomas 02ac0b512b fix checks 2026-06-08 10:26:45 -04:00
Brandon Thomas bac0dd6f3d space 2026-06-08 10:23:28 -04:00
Brandon Thomas 4e92c1390f token is responsibility of mysql module 2026-06-08 10:20:37 -04:00
Brandon Thomas 1e562c5dc1 spacing 2026-06-08 10:18:12 -04:00
Brandon Thomas a442cdddbb update path 2026-06-08 10:07:26 -04:00
Brandon Thomas 6e98b6f82e space 2026-06-08 10:03:01 -04:00
Brandon Thomas 5ca13a7eb7 migrate to executor generics 2026-06-08 09:58:59 -04:00
Brandon Thomas 150d9a10e8 move endpoints 2026-06-08 09:41:25 -04:00
Brandon Thomas b4f525f5e7 initial endpoints + queries 2026-06-08 09:31:59 -04:00
Brandon Thomas 62861ecc04 membership tables 2026-06-08 09:04:22 -04:00
Brandon Thomas aec3aa2939 folder token 2026-06-08 08:47:37 -04:00
Brandon Thomas a19ab28529 forgot this 2026-06-08 08:37:03 -04:00
Brandon Thomas 8678492874 schema 2026-06-08 08:36:36 -04:00
80 changed files with 4790 additions and 4 deletions
@@ -0,0 +1,179 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n f.id as `id: u64`,\n f.token as `token: FolderToken`,\n f.name,\n f.owner_user_token as `owner_user_token: UserToken`,\n f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`,\n f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`,\n f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`,\n f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`,\n f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`,\n f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`,\n f.maybe_color_code,\n f.has_star as `has_star: bool`,\n f.created_at,\n f.updated_at,\n (\n f.maybe_parent_folder_token IS NOT NULL\n AND (p.token IS NULL OR p.maybe_deleted_at IS NOT NULL)\n ) as `is_orphaned: bool`\nFROM folders f\nLEFT JOIN folders p\n ON p.token = f.maybe_parent_folder_token\nWHERE f.owner_user_token = ?\n AND f.maybe_deleted_at IS NULL\nORDER BY f.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: FolderToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_parent_folder_token: FolderToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_last_media_file_token_1: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_last_media_file_token_2: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_last_media_file_token_3: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_last_media_file_token_4: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_image_custom_media_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 10,
"name": "maybe_color_code",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "has_star: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 12,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 13,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 14,
"name": "is_orphaned: bool",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false,
false,
false
]
},
"hash": "05a880a2ece0236f57fe1c40100cba0c0cdcad2ebee0fecb88f9043ac7b82d8c"
}
@@ -0,0 +1,179 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n f.id as `id: u64`,\n f.token as `token: FolderToken`,\n f.name,\n f.owner_user_token as `owner_user_token: UserToken`,\n f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`,\n f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`,\n f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`,\n f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`,\n f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`,\n f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`,\n f.maybe_color_code,\n f.has_star as `has_star: bool`,\n f.created_at,\n f.updated_at,\n (\n f.maybe_parent_folder_token IS NOT NULL\n AND (p.token IS NULL OR p.maybe_deleted_at IS NOT NULL)\n ) as `is_orphaned: bool`\nFROM folders f\nLEFT JOIN folders p\n ON p.token = f.maybe_parent_folder_token\nWHERE f.token = ?\n AND f.owner_user_token = ?\n AND f.maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: FolderToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_parent_folder_token: FolderToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_last_media_file_token_1: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_last_media_file_token_2: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_last_media_file_token_3: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_last_media_file_token_4: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_image_custom_media_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 10,
"name": "maybe_color_code",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "has_star: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 12,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 13,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 14,
"name": "is_orphaned: bool",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false,
false,
false
]
},
"hash": "0c918aacf4ce34e1e03d2f9af49dd3138cadf59a28d0917726135ed5f34cba5e"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE folders\nSET maybe_cover_image_custom_media_token = ?\nWHERE token = ?\n AND owner_user_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "197e4fb3822b095cbbf8127493dad91c9254b7c242b8921a8c2df9f5a0e77424"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE folders\nSET has_star = ?\nWHERE token = ?\n AND owner_user_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "24716cfdb77eee365bd605a40cecb02b3a27a90725dbe96e3865545d14839ab8"
}
@@ -0,0 +1,157 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_type,\n mf.media_class,\n mf.maybe_mime_type,\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n mf.maybe_duration_millis,\n mf.maybe_title\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY fmf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "membership_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "added_to_folder_at: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 2,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "media_type",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "media_class",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 5,
"name": "maybe_mime_type",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 9,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 10,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 11,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 12,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
true
]
},
"hash": "491980e0a555a900354e60525947d77548a6cb8e4305d13886c8ad7e6787476c"
}
@@ -0,0 +1,58 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n maybe_creator_user_token as `maybe_creator_user_token: UserToken`,\n media_class as `media_class: MediaFileClass`,\n media_type as `media_type: MediaFileType`,\n maybe_cover_image_media_file_token as `maybe_cover_image_media_file_token: MediaFileToken`\nFROM media_files\nWHERE token = ?\n AND user_deleted_at IS NULL\n AND mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "maybe_creator_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 2,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "maybe_cover_image_media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
true
]
},
"hash": "5606a694c872d868ab3b35f4b3685f86f2b27ad16e741c8b1bf997b613f1b803"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE folders\nSET maybe_color_code = ?\nWHERE token = ?\n AND owner_user_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "6a0e3f012795068a56a0dfaf567d066dc157bb06c736c1c4314b18c8e0356127"
}
@@ -0,0 +1,25 @@
{
"db_name": "MySQL",
"query": "\nWITH RECURSIVE ancestor_chain (token, parent_token, depth) AS (\n SELECT token, maybe_parent_folder_token, 0\n FROM folders\n WHERE token = ?\n AND owner_user_token = ?\n AND maybe_deleted_at IS NULL\n UNION ALL\n SELECT f.token, f.maybe_parent_folder_token, ac.depth + 1\n FROM folders f\n INNER JOIN ancestor_chain ac\n ON f.token = ac.parent_token\n WHERE f.owner_user_token = ?\n AND f.maybe_deleted_at IS NULL\n AND ac.depth < ?\n)\nSELECT token as `token!: FolderToken`\nFROM ancestor_chain\nWHERE depth > 0\nORDER BY depth ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token!: FolderToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
true
]
},
"hash": "780891e8294b78cc0e5a05a8b5a8949a763bcb8c5d6526a621234020b154bdc9"
}
@@ -0,0 +1,168 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n f.id as `id: u64`,\n f.token as `token: FolderToken`,\n f.name,\n f.owner_user_token as `owner_user_token: UserToken`,\n f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`,\n f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`,\n f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`,\n f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`,\n f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`,\n f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`,\n f.maybe_color_code,\n f.has_star as `has_star: bool`,\n f.created_at,\n f.updated_at\nFROM folders f\nWHERE f.maybe_parent_folder_token = ?\n AND f.owner_user_token = ?\n AND f.maybe_deleted_at IS NULL\nORDER BY f.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: FolderToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_parent_folder_token: FolderToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_last_media_file_token_1: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_last_media_file_token_2: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_last_media_file_token_3: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_last_media_file_token_4: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_image_custom_media_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 10,
"name": "maybe_color_code",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "has_star: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 12,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 13,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false,
false
]
},
"hash": "a13680fb2b54809b6d9d9356f8eec87cd0f1f37405b84bbf734ed06cc244efb1"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO folders\nSET\n token = ?,\n name = ?,\n owner_user_token = ?,\n maybe_parent_folder_token = ?,\n maybe_color_code = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "c099b274026619760f0982f0e5082da1e8de16c26122f2750667b9cdc2c4abf3"
}
@@ -0,0 +1,179 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n f.id as `id: u64`,\n f.token as `token: FolderToken`,\n f.name,\n f.owner_user_token as `owner_user_token: UserToken`,\n f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`,\n f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`,\n f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`,\n f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`,\n f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`,\n f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`,\n f.maybe_color_code,\n f.has_star as `has_star: bool`,\n f.created_at,\n f.updated_at,\n (\n f.maybe_parent_folder_token IS NOT NULL\n AND (p.token IS NULL OR p.maybe_deleted_at IS NOT NULL)\n ) as `is_orphaned: bool`\nFROM folders f\nLEFT JOIN folders p\n ON p.token = f.maybe_parent_folder_token\nWHERE f.owner_user_token = ?\n AND f.maybe_deleted_at IS NULL\n AND f.id < ?\nORDER BY f.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: FolderToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_parent_folder_token: FolderToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_last_media_file_token_1: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_last_media_file_token_2: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_last_media_file_token_3: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_last_media_file_token_4: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_image_custom_media_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 10,
"name": "maybe_color_code",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "has_star: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 12,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 13,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 14,
"name": "is_orphaned: bool",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false,
false,
false
]
},
"hash": "d6f0c65abce2da20fe86805ff053b8c2569bf0095f6245a5529f0c71854a604b"
}
@@ -0,0 +1,168 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n f.id as `id: u64`,\n f.token as `token: FolderToken`,\n f.name,\n f.owner_user_token as `owner_user_token: UserToken`,\n f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`,\n f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`,\n f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`,\n f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`,\n f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`,\n f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`,\n f.maybe_color_code,\n f.has_star as `has_star: bool`,\n f.created_at,\n f.updated_at\nFROM folders f\nWHERE f.maybe_parent_folder_token = ?\n AND f.owner_user_token = ?\n AND f.maybe_deleted_at IS NULL\n AND f.id < ?\nORDER BY f.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: FolderToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "maybe_parent_folder_token: FolderToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_last_media_file_token_1: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_last_media_file_token_2: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_last_media_file_token_3: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_last_media_file_token_4: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_image_custom_media_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 10,
"name": "maybe_color_code",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "has_star: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 12,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 13,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false,
false
]
},
"hash": "d85c1a844f14b9416c6260d7a906fec709b50eb4583b98dffb52ebbe6abcb7d5"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE folders\nSET name = ?\nWHERE token = ?\n AND owner_user_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "e32e195ef41bc833e5b7fc53cfde8669f88aeb66d279e5213d047f4860ec86ab"
}
@@ -0,0 +1,157 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_type,\n mf.media_class,\n mf.maybe_mime_type,\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n mf.maybe_duration_millis,\n mf.maybe_title\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND fmf.id < ?\nORDER BY fmf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "membership_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "added_to_folder_at: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 2,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "media_type",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "media_class",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 5,
"name": "maybe_mime_type",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 9,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 10,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 11,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 12,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
true
]
},
"hash": "ea5f5e5b3caf990e9c25a3e8fe2e9625c12d6a36dd5dd91fc4615a45acdd33b8"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE folders\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND owner_user_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "f9e3ff8ca682948076b799d6b2d934be4a87a4e04665d6f12cc32095494129ad"
}
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS folders;
@@ -0,0 +1,56 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE folders (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (PUBLIC, used by the API).
token VARCHAR(32) NOT NULL,
-- User-supplied folder name.
name VARCHAR(255) NOT NULL,
-- The user that owns this folder.
owner_user_token VARCHAR(32) NOT NULL,
-- Parent folder for nesting, NULL for top-level folders.
maybe_parent_folder_token VARCHAR(32) DEFAULT NULL,
-- The last four media files added to the folder, in order. These are
-- denormalized for cheap thumbnail-grid rendering on folder cards
-- without a separate fan-out query.
maybe_last_media_file_token_1 VARCHAR(32) DEFAULT NULL,
maybe_last_media_file_token_2 VARCHAR(32) DEFAULT NULL,
maybe_last_media_file_token_3 VARCHAR(32) DEFAULT NULL,
maybe_last_media_file_token_4 VARCHAR(32) DEFAULT NULL,
-- Optional user-chosen cover image (a media file). Overrides the
-- auto-grid thumbnail derived from the last-N media files above.
maybe_cover_image_custom_media_token VARCHAR(32) DEFAULT NULL,
-- Optional user-chosen color tag. Wide enough for "#RRGGBBAA" plus a
-- short named-color fallback.
maybe_color_code VARCHAR(16) DEFAULT NULL,
-- Whether the user has starred / favorited this folder.
has_star BOOLEAN NOT NULL DEFAULT FALSE,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the folder is live.
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY fk_owner_user_token (owner_user_token),
KEY fk_maybe_parent_folder_token (maybe_parent_folder_token),
KEY index_folders_created_at (created_at),
KEY index_folders_updated_at (updated_at),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS folder_media_files;
@@ -0,0 +1,27 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE folder_media_files (
-- Auto-increment for posterity / row-creation ordering. Not the primary
-- key — the composite (folder_token, media_file_token) is. AUTO_INCREMENT
-- requires its own key, hence the UNIQUE KEY below.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The folder this membership belongs to.
folder_token VARCHAR(32) NOT NULL,
-- The media file in the folder.
media_file_token VARCHAR(32) NOT NULL,
-- When the media file was added to the folder. Rows are hard-deleted
-- when a media file is removed, so there is no `maybe_deleted_at`.
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (folder_token, media_file_token),
UNIQUE KEY (id),
KEY index_folder_token (folder_token),
KEY index_media_file_token (media_file_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,32 @@
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
-- with comments attached to the fields for centralized documentation.
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- N:M mapping between `folders` and `media_files`. Rows are hard-deleted
-- when a media file is removed from a folder — no soft-delete column.
CREATE TABLE folder_media_files (
-- Auto-increment for posterity / row-creation ordering. Not the primary
-- key — the composite (folder_token, media_file_token) is. AUTO_INCREMENT
-- requires its own key, hence the UNIQUE KEY below.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The folder this membership belongs to.
folder_token VARCHAR(32) NOT NULL,
-- The media file in the folder.
media_file_token VARCHAR(32) NOT NULL,
-- When the media file was added to the folder. Rows are hard-deleted
-- when a media file is removed, so there is no `maybe_deleted_at`.
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (folder_token, media_file_token),
UNIQUE KEY (id),
KEY index_folder_token (folder_token),
KEY index_media_file_token (media_file_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,59 @@
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
-- with comments attached to the fields for centralized documentation.
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE folders (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Effective "primary key" (PUBLIC, used by the API).
token VARCHAR(32) NOT NULL,
-- User-supplied folder name.
name VARCHAR(255) NOT NULL,
-- The user that owns this folder.
owner_user_token VARCHAR(32) NOT NULL,
-- Parent folder for nesting, NULL for top-level folders.
maybe_parent_folder_token VARCHAR(32) DEFAULT NULL,
-- The last four media files added to the folder, in order. These are
-- denormalized for cheap thumbnail-grid rendering on folder cards
-- without a separate fan-out query.
maybe_last_media_file_token_1 VARCHAR(32) DEFAULT NULL,
maybe_last_media_file_token_2 VARCHAR(32) DEFAULT NULL,
maybe_last_media_file_token_3 VARCHAR(32) DEFAULT NULL,
maybe_last_media_file_token_4 VARCHAR(32) DEFAULT NULL,
-- Optional user-chosen cover image (a media file). Overrides the
-- auto-grid thumbnail derived from the last-N media files above.
maybe_cover_image_custom_media_token VARCHAR(32) DEFAULT NULL,
-- Optional user-chosen color tag. Wide enough for "#RRGGBBAA" plus a
-- short named-color fallback.
maybe_color_code VARCHAR(16) DEFAULT NULL,
-- Whether the user has starred / favorited this folder.
has_star BOOLEAN NOT NULL DEFAULT FALSE,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Soft-delete timestamp. NULL means the folder is live.
maybe_deleted_at TIMESTAMP NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
KEY fk_owner_user_token (owner_user_token),
KEY fk_maybe_parent_folder_token (maybe_parent_folder_token),
KEY index_folders_created_at (created_at),
KEY index_folders_updated_at (updated_at),
KEY index_maybe_deleted_at (maybe_deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -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<FolderToken>,
pub maybe_last_media_file_token_1: Option<MediaFileToken>,
pub maybe_last_media_file_token_2: Option<MediaFileToken>,
pub maybe_last_media_file_token_3: Option<MediaFileToken>,
pub maybe_last_media_file_token_4: Option<MediaFileToken>,
pub maybe_cover_image_custom_media_token: Option<MediaFileToken>,
pub maybe_color_code: Option<String>,
pub has_star: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// 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<Utc>,
pub media_type: String,
pub media_class: String,
pub maybe_mime_type: Option<String>,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_frame_width: Option<i32>,
pub maybe_frame_height: Option<i32>,
pub maybe_duration_millis: Option<i32>,
pub maybe_title: Option<String>,
}
@@ -0,0 +1,116 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::folders::common::FolderInfo;
// ── POST /v1/folders/create ──
#[derive(Deserialize, ToSchema)]
pub struct CreateFolderRequest {
pub name: String,
pub maybe_parent_folder_token: Option<FolderToken>,
pub maybe_color_code: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct CreateFolderSuccessResponse {
pub success: bool,
pub folder: FolderInfo,
}
// ── GET /v1/folders/list_all ──
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListFoldersQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListFoldersSuccessResponse {
pub success: bool,
pub folders: Vec<FolderInfo>,
pub maybe_cursor: Option<String>,
}
// ── GET /v1/folders/folder/{folder_token} ──
#[derive(Deserialize, ToSchema)]
pub struct FolderPathInfo {
pub folder_token: FolderToken,
}
#[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<String>,
}
#[derive(Serialize, ToSchema)]
pub struct SetFolderColorCodeSuccessResponse {
pub success: bool,
}
// ── PUT /v1/folders/folder/{folder_token}/cover_image ──
#[derive(Deserialize, ToSchema)]
pub struct SetFolderCoverImageRequest {
/// `None` clears the cover image. If set, the server resolves it to a
/// usable cover-image media file token: image/video media files are
/// used directly; other types fall back to the file's own
/// `maybe_cover_image_media_file_token` (and fail if neither path
/// applies).
pub maybe_media_file_token: Option<MediaFileToken>,
}
#[derive(Serialize, ToSchema)]
pub struct SetFolderCoverImageSuccessResponse {
pub success: bool,
/// The media file token that ended up being stored as the cover image
/// (may differ from the requested token if the server resolved through
/// the file's own cover image). `None` when the cover was cleared.
pub maybe_resolved_cover_media_file_token: Option<MediaFileToken>,
}
// ── DELETE /v1/folders/folder/{folder_token} ──
#[derive(Serialize, ToSchema)]
pub struct DeleteFolderSuccessResponse {
pub success: bool,
}
@@ -0,0 +1,59 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::folders::FolderToken;
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: FolderToken,
}
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListFolderMediaFilesQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListFolderMediaFilesSuccessResponse {
pub success: bool,
pub media_files: Vec<FolderMediaFileInfo>,
pub maybe_cursor: Option<String>,
}
// ── 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<MediaFileToken>,
}
#[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<MediaFileToken>,
}
// ── PUT /v1/folders/media_files/{folder_token}/bulk_remove ──
#[derive(Deserialize, ToSchema)]
pub struct BulkRemoveFolderMediaFilesRequest {
pub media_file_tokens: Vec<MediaFileToken>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkRemoveFolderMediaFilesSuccessResponse {
pub success: bool,
/// How many membership rows were actually hard-deleted.
pub removed_count: u64,
}
@@ -0,0 +1,4 @@
pub mod common;
pub mod folder;
pub mod media_files;
pub mod subfolder;
@@ -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: FolderToken,
}
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListSubfoldersQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListSubfoldersSuccessResponse {
pub success: bool,
pub subfolders: Vec<FolderInfo>,
pub maybe_cursor: Option<String>,
}
// ── 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<FolderToken>,
}
#[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<FolderToken>,
}
// ── 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<FolderToken>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkRemoveSubfoldersSuccessResponse {
pub success: bool,
/// How many rows were actually unparented.
pub removed_count: u64,
}
@@ -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;
@@ -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<FolderToken>,
pub maybe_last_media_file_token_1: Option<MediaFileToken>,
pub maybe_last_media_file_token_2: Option<MediaFileToken>,
pub maybe_last_media_file_token_3: Option<MediaFileToken>,
pub maybe_last_media_file_token_4: Option<MediaFileToken>,
pub maybe_cover_image_custom_media_token: Option<MediaFileToken>,
pub maybe_color_code: Option<String>,
pub has_star: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub is_orphaned: bool,
}
@@ -0,0 +1,82 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
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 GetFolderForOwnerArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub folder_token: &'e FolderToken,
pub owner_user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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<'e, 'c: 'e, E>(
args: GetFolderForOwnerArgs<'e, 'c, E>,
) -> Result<Option<FolderRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
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
"#,
args.folder_token.as_str(),
args.owner_user_token.as_str(),
)
.fetch_optional(args.mysql_executor)
.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,
}))
}
@@ -0,0 +1,50 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
pub struct InsertFolderArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub name: &'e str,
pub owner_user_token: &'e UserToken,
pub maybe_parent_folder_token: Option<&'e FolderToken>,
pub maybe_color_code: Option<&'e str>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Insert a new folder row. The token is generated by this function and
/// returned to the caller (mints in the data-access layer, not the handler).
pub async fn insert_folder<'e, 'c: 'e, E>(
args: InsertFolderArgs<'e, 'c, E>,
) -> Result<FolderToken, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let token = FolderToken::generate();
sqlx::query!(
r#"
INSERT INTO folders
SET
token = ?,
name = ?,
owner_user_token = ?,
maybe_parent_folder_token = ?,
maybe_color_code = ?
"#,
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(args.mysql_executor)
.await?;
Ok(token)
}
@@ -0,0 +1,70 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
/// Maximum hops the recursive CTE will walk before giving up. Prevents
/// runaway recursion on already-corrupt data (a folder graph that
/// somehow contains a cycle).
const MAX_ANCESTOR_DEPTH: i64 = 64;
pub struct ListAncestorFolderTokensArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// The folder whose ancestor chain we're walking. Its own token is
/// NOT included in the result — only strict ancestors.
pub folder_token: &'e FolderToken,
/// Scope to a single owner so we never traverse into another user's
/// folder tree, even if the parent pointer were ever cross-owner.
pub owner_user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Return the strict ancestors of the given folder, walking up the
/// `maybe_parent_folder_token` chain. Order is nearest-ancestor first.
/// Deleted folders are excluded — a soft-deleted parent terminates the
/// chain. The walk is capped at [`MAX_ANCESTOR_DEPTH`] hops.
pub async fn list_ancestor_folder_tokens<'e, 'c: 'e, E>(
args: ListAncestorFolderTokensArgs<'e, 'c, E>,
) -> Result<Vec<FolderToken>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let rows = sqlx::query!(
r#"
WITH RECURSIVE ancestor_chain (token, parent_token, depth) AS (
SELECT token, maybe_parent_folder_token, 0
FROM folders
WHERE token = ?
AND owner_user_token = ?
AND maybe_deleted_at IS NULL
UNION ALL
SELECT f.token, f.maybe_parent_folder_token, ac.depth + 1
FROM folders f
INNER JOIN ancestor_chain ac
ON f.token = ac.parent_token
WHERE f.owner_user_token = ?
AND f.maybe_deleted_at IS NULL
AND ac.depth < ?
)
SELECT token as `token!: FolderToken`
FROM ancestor_chain
WHERE depth > 0
ORDER BY depth ASC
"#,
args.folder_token.as_str(),
args.owner_user_token.as_str(),
args.owner_user_token.as_str(),
MAX_ANCESTOR_DEPTH,
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows.into_iter().map(|r| r.token).collect())
}
@@ -0,0 +1,154 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
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<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub owner_user_token: &'e UserToken,
pub maybe_cursor_id: Option<u64>,
pub limit: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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<'e, 'c: 'e, E>(
args: ListFoldersForUserArgs<'e, 'c, E>,
) -> Result<Vec<FolderRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let limit = args.limit as i64;
// Two query branches because sqlx::query! can't accept a conditional
// WHERE clause at the macro level. `args.mysql_executor` is moved into
// exactly one arm at runtime — Rust permits this since the arms are
// mutually exclusive.
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.mysql_executor)
.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::<Vec<_>>()
}
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.mysql_executor)
.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::<Vec<_>>()
}
};
Ok(rows)
}
@@ -0,0 +1,10 @@
pub mod folder_row;
pub mod get_folder_for_owner;
pub mod insert_folder;
pub mod list_ancestor_folder_tokens;
pub mod list_folders_for_user;
pub mod soft_delete_folder;
pub mod update_folder_color_code;
pub mod update_folder_cover_image;
pub mod update_folder_has_star;
pub mod update_folder_name;
@@ -0,0 +1,42 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
pub struct SoftDeleteFolderArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub folder_token: &'e FolderToken,
pub owner_user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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<'e, 'c: 'e, E>(
args: SoftDeleteFolderArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
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
"#,
args.folder_token.as_str(),
args.owner_user_token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,42 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
pub struct UpdateFolderColorCodeArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub folder_token: &'e FolderToken,
pub owner_user_token: &'e UserToken,
pub maybe_color_code: Option<&'e str>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Set or clear (`None`) the color code on a folder.
pub async fn update_folder_color_code<'e, 'c: 'e, E>(
args: UpdateFolderColorCodeArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
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
"#,
args.maybe_color_code,
args.folder_token.as_str(),
args.owner_user_token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,46 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
pub struct UpdateFolderCoverImageArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub folder_token: &'e FolderToken,
pub owner_user_token: &'e UserToken,
/// `None` clears the cover image.
pub maybe_cover_image_media_file_token: Option<&'e MediaFileToken>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Set or clear (`None`) the custom cover-image media file on a folder.
pub async fn update_folder_cover_image<'e, 'c: 'e, E>(
args: UpdateFolderCoverImageArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
UPDATE folders
SET maybe_cover_image_custom_media_token = ?
WHERE token = ?
AND owner_user_token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
args.maybe_cover_image_media_file_token.map(|t| t.as_str()),
args.folder_token.as_str(),
args.owner_user_token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,41 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
pub struct UpdateFolderHasStarArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub folder_token: &'e FolderToken,
pub owner_user_token: &'e UserToken,
pub has_star: bool,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn update_folder_has_star<'e, 'c: 'e, E>(
args: UpdateFolderHasStarArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
UPDATE folders
SET has_star = ?
WHERE token = ?
AND owner_user_token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
args.has_star,
args.folder_token.as_str(),
args.owner_user_token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,44 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
pub struct UpdateFolderNameArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub folder_token: &'e FolderToken,
pub owner_user_token: &'e UserToken,
pub new_name: &'e str,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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<'e, 'c: 'e, E>(
args: UpdateFolderNameArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
UPDATE folders
SET name = ?
WHERE token = ?
AND owner_user_token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
args.new_name,
args.folder_token.as_str(),
args.owner_user_token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,45 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
pub struct BulkDeleteFolderMediaFilesArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub folder_token: &'e FolderToken,
pub media_file_tokens: &'e [MediaFileToken],
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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<'e, 'c: 'e, E>(
args: BulkDeleteFolderMediaFilesArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.media_file_tokens.is_empty() {
return Ok(0);
}
let mut builder = QueryBuilder::<MySql>::new(
"DELETE FROM folder_media_files WHERE folder_token = ",
);
builder.push_bind(args.folder_token.as_str());
builder.push(" AND media_file_token IN (");
let mut separated = builder.separated(", ");
for token in args.media_file_tokens {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
let result = builder.build().execute(args.mysql_executor).await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,43 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
pub struct BulkInsertFolderMediaFilesArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub folder_token: &'e FolderToken,
pub media_file_tokens: &'e [MediaFileToken],
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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<'e, 'c: 'e, E>(
args: BulkInsertFolderMediaFilesArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.media_file_tokens.is_empty() {
return Ok(0);
}
let folder_token_str = args.folder_token.as_str();
let mut builder = QueryBuilder::<MySql>::new(
"INSERT IGNORE INTO folder_media_files (folder_token, media_file_token) ",
);
builder.push_values(args.media_file_tokens, |mut b, media_file_token| {
b.push_bind(folder_token_str)
.push_bind(media_file_token.as_str());
});
let result = builder.build().execute(args.mysql_executor).await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,44 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder, Row};
use tokens::tokens::media_files::MediaFileToken;
pub struct FilterExistingMediaFileTokensArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub candidate_tokens: &'e [MediaFileToken],
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Return only the input tokens that currently exist and aren't
/// soft-deleted (no user_deleted_at or mod_deleted_at).
pub async fn filter_existing_media_file_tokens<'e, 'c: 'e, E>(
args: FilterExistingMediaFileTokensArgs<'e, 'c, E>,
) -> Result<Vec<MediaFileToken>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.candidate_tokens.is_empty() {
return Ok(Vec::new());
}
let mut builder = QueryBuilder::<MySql>::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 args.candidate_tokens {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
let rows = builder.build().fetch_all(args.mysql_executor).await?;
Ok(rows.into_iter()
.map(|row| MediaFileToken::new(row.get::<String, _>(0)))
.collect())
}
@@ -0,0 +1,158 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
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<Utc>,
pub media_file_token: MediaFileToken,
pub media_type: String,
pub media_class: String,
pub maybe_mime_type: Option<String>,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_frame_width: Option<i32>,
pub maybe_frame_height: Option<i32>,
pub maybe_duration_millis: Option<i32>,
pub maybe_title: Option<String>,
}
pub struct ListFolderMediaFilesArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub folder_token: &'e FolderToken,
pub maybe_cursor_id: Option<u64>,
pub limit: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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.
/// Most-recently-added first. The caller is expected to have already
/// authorized the folder access.
pub async fn list_folder_media_files<'e, 'c: 'e, E>(
args: ListFolderMediaFilesArgs<'e, 'c, E>,
) -> Result<Vec<FolderMediaFileRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
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<Utc>`,
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.mysql_executor)
.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::<Vec<_>>()
}
None => {
sqlx::query!(
r#"
SELECT
fmf.id as `membership_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
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.mysql_executor)
.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::<Vec<_>>()
}
};
Ok(rows)
}
@@ -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;
@@ -0,0 +1,3 @@
pub mod folder;
pub mod media_files;
pub mod subfolder;
@@ -0,0 +1,50 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
pub struct BulkClearParentFolderArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub child_tokens: &'e [FolderToken],
pub from_parent_token: &'e FolderToken,
pub owner_user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Clear `maybe_parent_folder_token` (set to NULL) on every owned, live
/// folder whose token is in `child_tokens` AND whose current parent is
/// `from_parent_token`. The parent guard makes the operation safe to run
/// idempotently — it won't accidentally unparent a folder that's a child
/// of a different parent.
pub async fn bulk_clear_parent_folder<'e, 'c: 'e, E>(
args: BulkClearParentFolderArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.child_tokens.is_empty() {
return Ok(0);
}
let mut builder = QueryBuilder::<MySql>::new(
"UPDATE folders SET maybe_parent_folder_token = NULL WHERE owner_user_token = ",
);
builder.push_bind(args.owner_user_token.as_str());
builder.push(" AND maybe_deleted_at IS NULL AND maybe_parent_folder_token = ");
builder.push_bind(args.from_parent_token.as_str());
builder.push(" AND token IN (");
let mut separated = builder.separated(", ");
for token in args.child_tokens {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
let result = builder.build().execute(args.mysql_executor).await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,67 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
pub struct BulkSetParentFolderArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub child_tokens: &'e [FolderToken],
pub new_parent_token: &'e FolderToken,
pub owner_user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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
/// whose value actually changed.
///
/// Self-referential parenting is prevented at two layers:
/// * Rust-side: the parent is filtered out of the candidate list
/// before the SQL is built.
/// * SQL-side: the WHERE clause includes `AND token != new_parent_token`
/// as a defense-in-depth guard.
pub async fn bulk_set_parent_folder<'e, 'c: 'e, E>(
args: BulkSetParentFolderArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
// Drop the parent from the candidate list. This makes the function
// self-protecting — callers don't have to remember to filter — and
// pairs with the SQL `token != new_parent_token` guard below.
let new_parent_str = args.new_parent_token.as_str();
let filtered_children: Vec<&FolderToken> = args.child_tokens
.iter()
.filter(|t| t.as_str() != new_parent_str)
.collect();
if filtered_children.is_empty() {
return Ok(0);
}
let mut builder = QueryBuilder::<MySql>::new(
"UPDATE folders SET maybe_parent_folder_token = ",
);
builder.push_bind(new_parent_str);
builder.push(" WHERE owner_user_token = ");
builder.push_bind(args.owner_user_token.as_str());
builder.push(" AND maybe_deleted_at IS NULL AND token != ");
builder.push_bind(new_parent_str);
builder.push(" AND token IN (");
let mut separated = builder.separated(", ");
for token in filtered_children {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
let result = builder.build().execute(args.mysql_executor).await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,51 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder, Row};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
pub struct FilterExistingOwnedFolderTokensArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub candidate_tokens: &'e [FolderToken],
pub owner_user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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<'e, 'c: 'e, E>(
args: FilterExistingOwnedFolderTokensArgs<'e, 'c, E>,
) -> Result<Vec<FolderToken>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.candidate_tokens.is_empty() {
return Ok(Vec::new());
}
let mut builder = QueryBuilder::<MySql>::new(
"SELECT token FROM folders WHERE owner_user_token = ",
);
builder.push_bind(args.owner_user_token.as_str());
builder.push(" AND maybe_deleted_at IS NULL AND token IN (");
let mut separated = builder.separated(", ");
for token in args.candidate_tokens {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
let rows = builder.build().fetch_all(args.mysql_executor).await?;
Ok(rows.into_iter()
.map(|row| FolderToken::new(row.get::<String, _>(0)))
.collect())
}
@@ -0,0 +1,143 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
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<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub parent_folder_token: &'e FolderToken,
pub owner_user_token: &'e UserToken,
pub maybe_cursor_id: Option<u64>,
pub limit: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// 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. `is_orphaned` is always `false` here:
/// by definition, the parent matched so it exists.
pub async fn list_subfolders<'e, 'c: 'e, E>(
args: ListSubfoldersArgs<'e, 'c, E>,
) -> Result<Vec<FolderRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
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.mysql_executor)
.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::<Vec<_>>()
}
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.mysql_executor)
.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::<Vec<_>>()
}
};
Ok(rows)
}
@@ -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;
@@ -0,0 +1,62 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
#[derive(Debug, Clone)]
pub struct MediaFileForCoverCheck {
pub maybe_creator_user_token: Option<UserToken>,
pub media_class: MediaFileClass,
pub media_type: MediaFileType,
pub maybe_cover_image_media_file_token: Option<MediaFileToken>,
}
pub struct LookupMediaFileForCoverCheckArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub media_file_token: &'e MediaFileToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Minimal lookup of a media file for cover-image authorization +
/// classification. Returns the creator user token (for ownership checks),
/// the media class and type (to decide whether the file can be used as a
/// cover directly), and the file's own self-referential cover-image token
/// (for the "use the cover's cover" fallback). Excludes soft-deleted rows.
pub async fn lookup_media_file_for_cover_check<'e, 'c: 'e, E>(
args: LookupMediaFileForCoverCheckArgs<'e, 'c, E>,
) -> Result<Option<MediaFileForCoverCheck>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
SELECT
maybe_creator_user_token as `maybe_creator_user_token: UserToken`,
media_class as `media_class: MediaFileClass`,
media_type as `media_type: MediaFileType`,
maybe_cover_image_media_file_token as `maybe_cover_image_media_file_token: MediaFileToken`
FROM media_files
WHERE token = ?
AND user_deleted_at IS NULL
AND mod_deleted_at IS NULL
LIMIT 1
"#,
args.media_file_token.as_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(result.map(|r| MediaFileForCoverCheck {
maybe_creator_user_token: r.maybe_creator_user_token,
media_class: r.media_class,
media_type: r.media_type,
maybe_cover_image_media_file_token: r.maybe_cover_image_media_file_token,
}))
}
@@ -1,4 +1,5 @@
pub mod batch_get_media_files;
pub mod batch_get_media_files_by_tokens;
pub mod get_media_file;
pub mod get_media_file_for_inference;
pub mod get_media_file_for_inference;
pub mod lookup_media_file_for_cover_check;
@@ -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;
@@ -33,6 +33,7 @@ pub(crate) enum TokenPrefix {
DebugLogEvent,
DownloadJob,
EmailSenderJob,
Folder,
GoogleSignInAccount,
InferenceJob,
MediaFile,
@@ -121,6 +122,7 @@ impl PrefixGenerator for TokenPrefix {
Self::DebugLogEvent => "dbg_",
Self::DownloadJob => "jdown_", // NB: Previously "JGUP:"
Self::EmailSenderJob => "email_job_",
Self::Folder => "folder_",
Self::GoogleSignInAccount => "gsi_",
Self::InferenceJob => "jinf_",
Self::MediaFile => "m_",
@@ -0,0 +1,17 @@
use std::fmt::Debug;
use serde::Deserialize;
use serde::Serialize;
use utoipa::ToSchema;
use crate::prefixes::TokenPrefix;
/// The primary key for folders.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, Default, ToSchema)]
#[cfg_attr(feature = "database", derive(sqlx::Type))]
#[cfg_attr(feature = "database", sqlx(transparent))]
pub struct FolderToken(pub String);
impl_mysql_token_from_row!(FolderToken);
impl_string_token!(FolderToken);
impl_crockford_generator!(FolderToken, 32usize, TokenPrefix::Folder, CrockfordLower);
@@ -10,6 +10,7 @@ pub mod characters;
pub mod comments;
pub mod email_sender_job_token;
pub mod firehose_entries;
pub mod folders;
pub mod generic_download_jobs;
pub mod generic_inference_jobs;
pub mod google_sign_in_accounts;
@@ -94,6 +94,26 @@ 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::folders::folder::color_code_folder_handler::*;
use crate::http_server::endpoints::folders::folder::cover_image_folder_handler::*;
use crate::http_server::endpoints::folders::folder::create_folder_handler::*;
use crate::http_server::endpoints::folders::folder::delete_folder_handler::*;
use crate::http_server::endpoints::folders::folder::get_folder_handler::*;
use crate::http_server::endpoints::folders::folder::list_folders_handler::*;
use crate::http_server::endpoints::folders::folder::rename_folder_handler::*;
use crate::http_server::endpoints::folders::folder::star_folder_handler::*;
use crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_files_handler::*;
use crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::*;
use crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::*;
use crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::*;
use crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::*;
use crate::http_server::endpoints::folders::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 +555,24 @@ 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::folders::folder::create_folder_handler::create_folder_handler,
crate::http_server::endpoints::folders::folder::list_folders_handler::list_folders_handler,
crate::http_server::endpoints::folders::folder::get_folder_handler::get_folder_handler,
crate::http_server::endpoints::folders::folder::rename_folder_handler::rename_folder_handler,
crate::http_server::endpoints::folders::folder::star_folder_handler::star_folder_handler,
crate::http_server::endpoints::folders::folder::color_code_folder_handler::color_code_folder_handler,
crate::http_server::endpoints::folders::folder::cover_image_folder_handler::cover_image_folder_handler,
crate::http_server::endpoints::folders::folder::delete_folder_handler::delete_folder_handler,
crate::http_server::endpoints::folders::subfolder::list_subfolders_handler::list_subfolders_handler,
crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler,
crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler,
crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::list_folder_media_files_handler,
crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler,
crate::http_server::endpoints::folders::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 +1241,39 @@ 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,
SetFolderCoverImageRequest,
SetFolderCoverImageSuccessResponse,
DeleteFolderSuccessResponse,
ListSubfoldersQueryParams,
ListSubfoldersSuccessResponse,
BulkAddSubfoldersRequest,
BulkAddSubfoldersSuccessResponse,
BulkRemoveSubfoldersRequest,
BulkRemoveSubfoldersSuccessResponse,
ListFolderMediaFilesQueryParams,
ListFolderMediaFilesSuccessResponse,
BulkAddFolderMediaFilesRequest,
BulkAddFolderMediaFilesSuccessResponse,
BulkRemoveFolderMediaFilesRequest,
BulkRemoveFolderMediaFilesSuccessResponse,
))
)]
pub struct ApiDoc;
@@ -0,0 +1,95 @@
use std::marker::PhantomData;
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::get_folder_for_owner::{
get_folder_for_owner, GetFolderForOwnerArgs,
};
use mysql_queries::queries::folders::folder::update_folder_color_code::{
update_folder_color_code, UpdateFolderColorCodeArgs,
};
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" = FolderToken, 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<FolderPathInfo>,
request: Json<SetFolderColorCodeRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<SetFolderColorCodeSuccessResponse>, 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<String> = 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),
));
}
}
// Confirm the folder exists + is owned before issuing the update.
// A 404 here is the authoritative "not found" signal — the update
// itself is idempotent and we don't gate on rows_affected.
get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?
.ok_or(CommonWebError::NotFound)?;
update_folder_color_code(UpdateFolderColorCodeArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
maybe_color_code: trimmed.as_deref(),
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("update_folder_color_code failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(SetFolderColorCodeSuccessResponse { success: true }))
}
@@ -0,0 +1,178 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use sqlx::pool::PoolConnection;
use artcraft_api_defs::folders::folder::{
FolderPathInfo, SetFolderCoverImageRequest, SetFolderCoverImageSuccessResponse,
};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use mysql_queries::queries::folders::folder::get_folder_for_owner::{
get_folder_for_owner, GetFolderForOwnerArgs,
};
use mysql_queries::queries::folders::folder::update_folder_cover_image::{
update_folder_cover_image, UpdateFolderCoverImageArgs,
};
use mysql_queries::queries::media_files::get::lookup_media_file_for_cover_check::{
lookup_media_file_for_cover_check, LookupMediaFileForCoverCheckArgs,
};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
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;
/// Set or clear the custom cover image on a folder.
///
/// If `maybe_media_file_token` is `None`, the cover is cleared. Otherwise
/// the server resolves the supplied media file to a usable cover token:
///
/// 1. The media file must exist, not be soft-deleted, and be owned by the
/// requesting user (same owner as the folder).
/// 2. If the media file has its own `maybe_cover_image_media_file_token`
/// set, that cover token wins outright and is used as the folder
/// cover.
/// 3. Otherwise, if the media file's `media_class` is `image`/`video` OR
/// its `media_type` is `jpg`/`png`/`mp4`, the file's own token is used.
/// 4. If neither path applies, the request is rejected with 400.
#[utoipa::path(
put,
tag = "Folders",
path = "/v1/folders/folder/{folder_token}/cover_image",
params(("folder_token" = FolderToken, description = "Folder token")),
request_body = SetFolderCoverImageRequest,
responses(
(status = 200, body = SetFolderCoverImageSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn cover_image_folder_handler(
http_request: HttpRequest,
path: Path<FolderPathInfo>,
request: Json<SetFolderCoverImageRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<SetFolderCoverImageSuccessResponse>, 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)?;
// Confirm the folder exists + is owned. Authoritative 404 source.
get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?
.ok_or(CommonWebError::NotFound)?;
// Resolve the requested media file to a usable cover token (or `None`
// if clearing).
let maybe_resolved_token = match &request.maybe_media_file_token {
None => None,
Some(media_file_token) => Some(
resolve_cover_image_token(
media_file_token,
&user_session.user_token,
&mut conn,
).await?,
),
};
update_folder_cover_image(UpdateFolderCoverImageArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
maybe_cover_image_media_file_token: maybe_resolved_token.as_ref(),
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("update_folder_cover_image failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(SetFolderCoverImageSuccessResponse {
success: true,
maybe_resolved_cover_media_file_token: maybe_resolved_token,
}))
}
/// Look up `media_file_token`, verify ownership matches the session, and
/// pick a usable token to store. The file's own cover image wins outright
/// — if `maybe_cover_image_media_file_token` is set, that token is
/// returned regardless of the file's class/type. Otherwise we fall back
/// to the file itself if it's a directly-renderable image/video, and
/// `Err(CommonWebError::BadInputWithSimpleMessage(_))` if not.
async fn resolve_cover_image_token(
media_file_token: &MediaFileToken,
session_user_token: &UserToken,
conn: &mut PoolConnection<sqlx::MySql>,
) -> Result<MediaFileToken, CommonWebError> {
let media_file = lookup_media_file_for_cover_check(LookupMediaFileForCoverCheckArgs {
media_file_token,
mysql_executor: &mut **conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("lookup_media_file_for_cover_check failed: {:?}", err);
CommonWebError::from_error(err)
})?
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage(
"media file does not exist".to_string(),
))?;
let creator_matches = media_file.maybe_creator_user_token
.as_ref()
.map(|t| t.as_str() == session_user_token.as_str())
.unwrap_or(false);
if !creator_matches {
// Don't leak whether the file exists; "not yours" reads the same as
// "doesn't exist" from the client's perspective.
return Err(CommonWebError::BadInputWithSimpleMessage(
"media file does not exist".to_string(),
));
}
// The file's own cover image wins outright — if there is one, we use
// it regardless of the underlying file's class/type. This covers
// arbitrary 3D/audio/scene files that have a curated thumbnail.
if let Some(cover_token) = media_file.maybe_cover_image_media_file_token {
return Ok(cover_token);
}
// No cover image present — fall back to using the file itself, which
// is only valid if it's directly renderable.
if is_directly_usable_as_cover(media_file.media_class, media_file.media_type) {
return Ok(media_file_token.clone());
}
Err(CommonWebError::BadInputWithSimpleMessage(
"media file has no cover image and isn't a renderable image or video".to_string(),
))
}
/// A media file can be used as a cover directly if it's classified as an
/// image/video OR if its concrete type is one of the well-known
/// browser-renderable formats. We check both because `media_class` is a
/// soft category that some legacy rows leave as `unknown`, while
/// `media_type` is the more reliable indicator for newer rows.
fn is_directly_usable_as_cover(
media_class: MediaFileClass,
media_type: MediaFileType,
) -> bool {
matches!(media_class, MediaFileClass::Image | MediaFileClass::Video)
|| matches!(media_type, MediaFileType::Jpg | MediaFileType::Png | MediaFileType::Mp4)
}
@@ -0,0 +1,110 @@
use std::marker::PhantomData;
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, GetFolderForOwnerArgs,
};
use mysql_queries::queries::folders::folder::insert_folder::{insert_folder, InsertFolderArgs};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::folders::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<CreateFolderRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<CreateFolderSuccessResponse>, 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(GetFolderForOwnerArgs {
folder_token: parent_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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 = insert_folder(InsertFolderArgs {
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(),
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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(GetFolderForOwnerArgs {
folder_token: &token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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,
}))
}
@@ -0,0 +1,62 @@
use std::marker::PhantomData;
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, SoftDeleteFolderArgs,
};
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" = FolderToken, 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<FolderPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<DeleteFolderSuccessResponse>, 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 rows_affected = soft_delete_folder(SoftDeleteFolderArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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 }))
}
@@ -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,
}
}
@@ -0,0 +1,62 @@
use std::marker::PhantomData;
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, GetFolderForOwnerArgs,
};
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::folders::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" = FolderToken, 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<FolderPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<GetFolderSuccessResponse>, 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 row = get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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),
}))
}
@@ -0,0 +1,92 @@
use std::marker::PhantomData;
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::folders::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<ListFoldersQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListFoldersSuccessResponse>, 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,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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,
}))
}
@@ -0,0 +1,9 @@
pub mod color_code_folder_handler;
pub mod cover_image_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;
@@ -0,0 +1,92 @@
use std::marker::PhantomData;
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::get_folder_for_owner::{
get_folder_for_owner, GetFolderForOwnerArgs,
};
use mysql_queries::queries::folders::folder::update_folder_name::{
update_folder_name, UpdateFolderNameArgs,
};
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" = FolderToken, 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<FolderPathInfo>,
request: Json<RenameFolderRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<RenameFolderSuccessResponse>, 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),
));
}
// Confirm the folder exists + is owned before issuing the update.
// A 404 here is the authoritative "not found" signal — the update
// itself is idempotent and we don't gate on rows_affected.
get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?
.ok_or(CommonWebError::NotFound)?;
update_folder_name(UpdateFolderNameArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
new_name: &new_name,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("update_folder_name failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(RenameFolderSuccessResponse { success: true }))
}
@@ -0,0 +1,77 @@
use std::marker::PhantomData;
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::get_folder_for_owner::{
get_folder_for_owner, GetFolderForOwnerArgs,
};
use mysql_queries::queries::folders::folder::update_folder_has_star::{
update_folder_has_star, UpdateFolderHasStarArgs,
};
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" = FolderToken, 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<FolderPathInfo>,
request: Json<SetFolderStarRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<SetFolderStarSuccessResponse>, 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)?;
// Confirm the folder exists + is owned before issuing the update.
// A 404 here is the authoritative "not found" signal — the update
// itself is idempotent and we don't gate on rows_affected.
get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?
.ok_or(CommonWebError::NotFound)?;
update_folder_has_star(UpdateFolderHasStarArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
has_star: request.has_star,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("update_folder_has_star failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(SetFolderStarSuccessResponse { success: true }))
}
@@ -0,0 +1,107 @@
use std::marker::PhantomData;
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, GetFolderForOwnerArgs,
};
use mysql_queries::queries::folders::media_files::bulk_insert_folder_media_files::{
bulk_insert_folder_media_files, BulkInsertFolderMediaFilesArgs,
};
use mysql_queries::queries::folders::media_files::filter_existing_media_file_tokens::{
filter_existing_media_file_tokens, FilterExistingMediaFileTokensArgs,
};
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 (Media File Management)",
path = "/v1/folders/media_files/{folder_token}/bulk_add",
params(("folder_token" = FolderToken, 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<FolderMediaFilesPathInfo>,
request: Json<BulkAddFolderMediaFilesRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkAddFolderMediaFilesSuccessResponse>, 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 = get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if folder.is_none() {
return Err(CommonWebError::NotFound);
}
let accepted = filter_existing_media_file_tokens(FilterExistingMediaFileTokensArgs {
candidate_tokens: &request.media_file_tokens,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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(BulkInsertFolderMediaFilesArgs {
folder_token: &path.folder_token,
media_file_tokens: &accepted,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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,
}))
}
@@ -0,0 +1,91 @@
use std::marker::PhantomData;
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, GetFolderForOwnerArgs,
};
use mysql_queries::queries::folders::media_files::bulk_delete_folder_media_files::{
bulk_delete_folder_media_files, BulkDeleteFolderMediaFilesArgs,
};
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 (Media File Management)",
path = "/v1/folders/media_files/{folder_token}/bulk_remove",
params(("folder_token" = FolderToken, 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<FolderMediaFilesPathInfo>,
request: Json<BulkRemoveFolderMediaFilesRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkRemoveFolderMediaFilesSuccessResponse>, 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 = get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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(BulkDeleteFolderMediaFilesArgs {
folder_token: &path.folder_token,
media_file_tokens: &request.media_file_tokens,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("bulk_delete_folder_media_files failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(BulkRemoveFolderMediaFilesSuccessResponse {
success: true,
removed_count,
}))
}
@@ -0,0 +1,113 @@
use std::marker::PhantomData;
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, GetFolderForOwnerArgs,
};
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::folders::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 (Media File Management)",
path = "/v1/folders/media_files/{folder_token}",
params(
("folder_token" = FolderToken, 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<FolderMediaFilesPathInfo>,
query: Query<ListFolderMediaFilesQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListFolderMediaFilesSuccessResponse>, 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 = get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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: &path.folder_token,
maybe_cursor_id,
limit,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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,
}))
}
@@ -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;
@@ -0,0 +1,3 @@
pub mod folder;
pub mod media_files;
pub mod subfolder;
@@ -0,0 +1,148 @@
use std::marker::PhantomData;
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 std::collections::HashSet;
use mysql_queries::queries::folders::folder::get_folder_for_owner::{
get_folder_for_owner, GetFolderForOwnerArgs,
};
use mysql_queries::queries::folders::folder::list_ancestor_folder_tokens::{
list_ancestor_folder_tokens, ListAncestorFolderTokensArgs,
};
use mysql_queries::queries::folders::subfolder::bulk_set_parent_folder::{
bulk_set_parent_folder, BulkSetParentFolderArgs,
};
use mysql_queries::queries::folders::subfolder::filter_existing_owned_folder_tokens::{
filter_existing_owned_folder_tokens, FilterExistingOwnedFolderTokensArgs,
};
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 (Subfolder Management)",
path = "/v1/folders/subfolders/{folder_token}/bulk_add",
params(("folder_token" = FolderToken, 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<SubfolderPathInfo>,
request: Json<BulkAddSubfoldersRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkAddSubfoldersSuccessResponse>, 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 = get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Parent folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let parent = match parent {
Some(parent) => parent,
None => return Err(CommonWebError::NotFound),
};
// Drop any self-reference up front. A folder can't be its own parent;
// reporting it as "accepted" would be a lie since the SQL guard
// silently excludes it from the UPDATE.
let parent_str = parent.token.as_str();
let candidates: Vec<FolderToken> = request.subfolder_tokens
.iter()
.filter(|t| t.as_str() != parent_str)
.cloned()
.collect();
// Block ancestor cycles: reparenting a folder under one of its own
// descendants would close a loop. Equivalently, the new parent must
// not be a descendant of any candidate — i.e. no candidate may appear
// in the new parent's ancestor chain.
let ancestor_tokens = list_ancestor_folder_tokens(ListAncestorFolderTokensArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("list_ancestor_folder_tokens failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let ancestor_set: HashSet<&str> = ancestor_tokens
.iter()
.map(|t| t.as_str())
.collect();
let candidates: Vec<FolderToken> = candidates
.into_iter()
.filter(|t| !ancestor_set.contains(t.as_str()))
.collect();
let accepted = filter_existing_owned_folder_tokens(FilterExistingOwnedFolderTokensArgs {
candidate_tokens: &candidates,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("filter_existing_owned_folder_tokens failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if !accepted.is_empty() {
bulk_set_parent_folder(BulkSetParentFolderArgs {
child_tokens: &accepted,
new_parent_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("bulk_set_parent_folder failed: {:?}", err);
CommonWebError::from_error(err)
})?;
}
Ok(Json(BulkAddSubfoldersSuccessResponse {
success: true,
accepted_subfolder_tokens: accepted,
}))
}
@@ -0,0 +1,76 @@
use std::marker::PhantomData;
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, BulkClearParentFolderArgs,
};
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 (Subfolder Management)",
path = "/v1/folders/subfolders/{folder_token}/bulk_remove",
params(("folder_token" = FolderToken, 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<SubfolderPathInfo>,
request: Json<BulkRemoveSubfoldersRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkRemoveSubfoldersSuccessResponse>, 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),
));
}
// 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(BulkClearParentFolderArgs {
child_tokens: &request.subfolder_tokens,
from_parent_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("bulk_clear_parent_folder failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(BulkRemoveSubfoldersSuccessResponse {
success: true,
removed_count,
}))
}
@@ -0,0 +1,115 @@
use std::marker::PhantomData;
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, GetFolderForOwnerArgs,
};
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::folders::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 (Subfolder Management)",
path = "/v1/folders/subfolders/{folder_token}",
params(
("folder_token" = FolderToken, 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<SubfolderPathInfo>,
query: Query<ListSubfoldersQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListSubfoldersSuccessResponse>, 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)?;
// Confirm the parent exists + is owned by the caller before listing.
let parent = get_folder_for_owner(GetFolderForOwnerArgs {
folder_token: &path.folder_token,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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: &path.folder_token,
owner_user_token: &user_session.user_token,
maybe_cursor_id,
limit,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).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,
}))
}
@@ -0,0 +1,3 @@
pub mod bulk_add_subfolders_handler;
pub mod bulk_remove_subfolders_handler;
pub mod list_subfolders_handler;
@@ -8,6 +8,7 @@ pub mod credits;
pub mod dev;
pub mod download_job;
pub mod featured_items;
pub mod folders;
pub mod generate;
pub mod image_studio;
pub mod inference_job;
@@ -31,7 +31,7 @@ pub struct ModeratorListUserEmailChangesForUserPathInfo {
pub username: String,
}
#[derive(Deserialize, IntoParams)]
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ModeratorListUserEmailChangesQueryParams {
/// Opaque cursor from a previous page's `maybe_cursor`. Omit for the
/// first page.
@@ -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/...
@@ -0,0 +1,113 @@
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::folders::folder::color_code_folder_handler::color_code_folder_handler;
use crate::http_server::endpoints::folders::folder::cover_image_folder_handler::cover_image_folder_handler;
use crate::http_server::endpoints::folders::folder::create_folder_handler::create_folder_handler;
use crate::http_server::endpoints::folders::folder::delete_folder_handler::delete_folder_handler;
use crate::http_server::endpoints::folders::folder::get_folder_handler::get_folder_handler;
use crate::http_server::endpoints::folders::folder::list_folders_handler::list_folders_handler;
use crate::http_server::endpoints::folders::folder::rename_folder_handler::rename_folder_handler;
use crate::http_server::endpoints::folders::folder::star_folder_handler::star_folder_handler;
use crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler;
use crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler;
use crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::list_folder_media_files_handler;
use crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler;
use crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler;
use crate::http_server::endpoints::folders::subfolder::list_subfolders_handler::list_subfolders_handler;
pub fn add_folders_routes<T, B>(app: App<T>) -> App<T>
where
B: MessageBody,
T: ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<B>,
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())),
)
.service(
web::resource("/{folder_token}/cover_image")
.route(web::put().to(cover_image_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())),
),
),
)
}
@@ -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;