mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-01 23:35:40 -05:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09c30bf4a9 | ||
|
|
c98c6b708e | ||
|
|
f674aec46c | ||
|
|
3f2086d85b | ||
|
|
2844314776 | ||
|
|
fd42d33bdd | ||
|
|
02ac0b512b | ||
|
|
bac0dd6f3d | ||
|
|
4e92c1390f | ||
|
|
1e562c5dc1 | ||
|
|
a442cdddbb | ||
|
|
6e98b6f82e | ||
|
|
5ca13a7eb7 | ||
|
|
150d9a10e8 | ||
|
|
b4f525f5e7 | ||
|
|
62861ecc04 | ||
|
|
aec3aa2939 | ||
|
|
a19ab28529 | ||
|
|
8678492874 |
+179
@@ -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"
|
||||
}
|
||||
+179
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+157
@@ -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"
|
||||
}
|
||||
+58
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+168
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+179
@@ -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"
|
||||
}
|
||||
+168
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+157
@@ -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"
|
||||
}
|
||||
+12
@@ -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,
|
||||
}
|
||||
+82
@@ -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)
|
||||
}
|
||||
+70
@@ -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())
|
||||
}
|
||||
+154
@@ -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())
|
||||
}
|
||||
+42
@@ -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())
|
||||
}
|
||||
+46
@@ -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())
|
||||
}
|
||||
+41
@@ -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())
|
||||
}
|
||||
+45
@@ -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())
|
||||
}
|
||||
+43
@@ -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())
|
||||
}
|
||||
+44
@@ -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())
|
||||
}
|
||||
+158
@@ -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;
|
||||
+50
@@ -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())
|
||||
}
|
||||
+67
@@ -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())
|
||||
}
|
||||
+51
@@ -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())
|
||||
}
|
||||
+143
@@ -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;
|
||||
+62
@@ -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;
|
||||
|
||||
+95
@@ -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 }))
|
||||
}
|
||||
+178
@@ -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)
|
||||
}
|
||||
+110
@@ -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,
|
||||
}))
|
||||
}
|
||||
+62
@@ -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 }))
|
||||
}
|
||||
+39
@@ -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,
|
||||
}
|
||||
}
|
||||
+62
@@ -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),
|
||||
}))
|
||||
}
|
||||
+92
@@ -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;
|
||||
+92
@@ -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 }))
|
||||
}
|
||||
+77
@@ -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 }))
|
||||
}
|
||||
+107
@@ -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,
|
||||
}))
|
||||
}
|
||||
+91
@@ -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,
|
||||
}))
|
||||
}
|
||||
+113
@@ -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,
|
||||
}))
|
||||
}
|
||||
+3
@@ -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;
|
||||
+148
@@ -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,
|
||||
}))
|
||||
}
|
||||
+76
@@ -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,
|
||||
}))
|
||||
}
|
||||
+115
@@ -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;
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+2
@@ -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/...
|
||||
|
||||
|
||||
+113
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user