Move & Reorder
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”function ใหม่หนึ่งตัว move_card เพิ่มเข้า cards/service.rs บวก handler กับ DTO MoveCardRequest เพิ่มเข้า cards/handlers.rs บวก route ใหม่หนึ่งตัว PATCH /cards/:id/move เพิ่มเข้า cards/mod.rs นี่คือ endpoint ที่ทุกบทก่อนหน้าใน module นี้กำลังค่อย ๆ สร้างไปหา: ลาก card ไปตำแหน่งใหม่ — อาจเป็น column อื่นก็ได้ — แล้วบันทึกตำแหน่งที่ card ตกลงพอดีด้วยการเขียนแค่แถวเดียว โดยใช้กลยุทธ์ position แบบ fractional จาก indexes-ordering
move_card รับ state: &AppState ไม่ใช่ db: &PgPool เหมือน function อื่น ๆ ใน service.rs ของ module นี้ — ความต่างของ signature ที่ตั้งใจหนึ่งเดียวในทั้ง REST API module อธิบายด้านล่าง
move_card รับ &AppState แทน &PgPool เพราะเป็น function เดียวในทั้ง module นี้ที่ Module 7 (Realtime) จะขยายด้วยความสามารถที่สอง: broadcast event card.moved ไปยังทุก client อื่นที่กำลังดู board เดียวกันอยู่ ผ่าน WebSocket hub ที่ module นั้นสร้าง hub นั้นจะอยู่บน AppState เหมือนกับที่ db และ redis อยู่แล้ว — การให้ move_card ถือ AppState ทั้งตัวตั้งแต่ตอนนี้ แม้จะเอื้อมไปแค่ state.db จึงหมายความว่า Module 7 เพิ่มแค่บรรทัดเดียว (การเรียก broadcast) โดยไม่ต้องเปลี่ยน signature ของ function นี้เลย และไม่ต้องแตะจุดเรียกของ cards::handlers::move_card ด้วย comment // Module 7 wires realtime broadcast of card.moved here ทำเครื่องหมายจุดที่บรรทัดนั้นจะไปอยู่พอดี
เลขคณิตตำแหน่งเองคือการแปลตรงตัวของกฎที่ indexes-ordering กำหนดไว้แล้ว: หาค่าเฉลี่ยของเพื่อนบ้านสองข้าง, ก้าว ±1.0 ผ่านปลายข้างใดข้างหนึ่ง, หรือเริ่มที่ 1.0 สำหรับ target ที่ว่างเปล่า หน้าที่ของ move_card คือระบุให้ถูกว่ากรณีไหนที่ใช้ได้จาก before_id/after_id และปฏิเสธ request ที่พยายามย้าย card ไปอีก board อย่างถูกต้อง — เลขคณิตเองถูกตัดสินใจไปแล้วสอง module ก่อนหน้านี้
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”before_id/after_id เป็น Option<Uuid> สองตัวที่ระบุเพื่อนบ้านใหม่ของ card (ตัวที่เราใช้) เทียบกับ client ส่ง target position: f64 ดิบที่คำนวณเอง
- ข้อดี: server เป็นที่เดียวที่ implement สูตร fractional-position — client (Kanban frontend ใน Module 9) แค่ต้องบอกว่า “ฉันวาง card นี้ระหว่าง card A กับ card B” (หรือ “ที่บนสุด” หรือ “ที่ล่างสุด” หรือ “เข้า column ว่าง”) โดยใช้ id ที่มีอยู่แล้วจากการ render board ไม่มีวันต้องรู้ตำแหน่งตัวเลขปัจจุบันของอะไรเลย เลยไม่มีวันส่งค่าที่ชนกับ หรือ drift ไม่ตรงลำดับเมื่อเทียบกับตำแหน่งของเพื่อนบ้านเพราะการอ่านฝั่ง client ที่เก่าเกินไปได้
- ข้อเสีย:
move_cardจ่ายค่าSELECTเพิ่มสูงสุดสองครั้ง — ดึงbeforeกับafterด้วย id ถ้าให้มา — ก่อนที่จะคำนวณอะไรได้เลย ส่วนposition: f64ที่ client ส่งมาจะไม่ต้องการ query เพิ่มเลย นี่คือ trade-off ที่ถูกต้อง: การเชื่อให้ client คำนวณตำแหน่งเองถูกต้องหมายถึงต้องเชื่อให้ทุก frontend ในอนาคต (web ตอนนี้ อาจมี mobile ทีหลัง) implement กฎ fractional-averaging เดียวกันเป๊ะได้เหมือนกันและไม่มีวันส่งค่าเก่า — พื้นที่ที่ตรวจสอบยากกว่าและใหญ่กว่ามากเทียบกับ indexed primary-key lookup สองครั้งต่อการย้ายหนึ่งครั้ง
ปฏิเสธการย้ายข้าม-board ด้วย AppError::Forbidden (ตัวที่เราใช้) เทียบกับเปลี่ยน parent ของ card อย่างเงียบ ๆ ไปยัง board ที่เพื่อนบ้าน before/after เป็นของ
- ข้อดี:
target_column.board_id != source_board_idถูกเช็คและปฏิเสธอย่างชัดเจน ด้วย403ก่อน เลขคณิตตำแหน่งจะรัน หรือก่อนจะเขียนแถวไหนด้วยซ้ำ — สมาชิกของ board A ไม่มีวันใช้move_cardลักลอบย้าย card จาก board A ไปยัง column บน board B ได้ แม้ว่าเขาจะบังเอิญเป็นสมาชิกของ board B ด้วยก็ตาม (ช่องโหว่ประเภทเดียวกับที่ labels เคยเช็คสำหรับ attach/detach มาแล้ว ที่นี่ใช้กับการกระทำ move แทน) - ข้อเสีย: นี่ทำให้การย้าย card ข้าม-board ไม่ถูกรองรับเลยโดยสิ้นเชิง — ไม่มี endpoint ไหนในทั้ง API นี้สำหรับ “ย้าย card นี้ไปอีก board” มีแค่ “ย้าย card นี้ไปอีก column บน board เดียวกัน” ถ้า TaskFlow ต้องการเป็นฟีเจอร์จริงสักวัน (เช่น การกระทำ “คัดลอก card นี้ไปอีก board” ที่ตั้งใจ) ก็ต้องมี endpoint ที่ชัดเจนของตัวเอง พร้อม authorization story ของตัวเอง — ไม่ใช่ผลข้างเคียงจากการผ่อน check นี้
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”1. move_card ใน cards/repo.rs
หัวข้อที่มีชื่อว่า “1. move_card ใน cards/repo.rs”เพิ่ม function นี้เข้าไฟล์ taskflow/backend/api/src/cards/repo.rs ที่สร้างไว้ใน cards:
pub async fn move_card( db: &PgPool, id: Uuid, column_id: Uuid, position: f64,) -> AppResult<Option<Card>> { let card = sqlx::query_as::<_, Card>( "UPDATE cards SET column_id = $2, position = $3 WHERE id = $1 RETURNING id, column_id, title, description, position, created_at", ) .bind(id) .bind(column_id) .bind(position) .fetch_optional(db) .await?;
Ok(card)}UPDATE เดียวเขียนทั้ง column_id และ position ในคำสั่งเดียว — การย้ายภายใน column เดียวกัน (column_id ไม่เปลี่ยน) และการย้ายข้าม column เป็น query เดียวกัน เพราะการตั้ง column ให้เท่ากับค่าเดิมเป็น no-op ที่ไม่มีผลเสียหาย
2. move_card ใน cards/service.rs
หัวข้อที่มีชื่อว่า “2. move_card ใน cards/service.rs”เพิ่ม function นี้เข้า cards/service.rs คู่กับ create_card, get_card, update_card, และ delete_card จาก cards — ต้อง import crate::state::AppState เข้าด้านบนไฟล์ด้วย:
pub async fn move_card( state: &AppState, user_id: Uuid, card_id: Uuid, target_column_id: Uuid, before_id: Option<Uuid>, after_id: Option<Uuid>,) -> AppResult<Card> { let db = &state.db;
let card = repo::find_card(db, card_id) .await? .ok_or(AppError::NotFound)?; let source_board_id = card_board_id(db, &card).await?; boards_service::assert_member(db, user_id, source_board_id).await?;
let target_column = columns::repo::find_column(db, target_column_id) .await? .ok_or(AppError::NotFound)?; if target_column.board_id != source_board_id { return Err(AppError::Forbidden); }
let before = match before_id { Some(id) => Some(repo::find_card(db, id).await?.ok_or(AppError::NotFound)?), None => None, }; let after = match after_id { Some(id) => Some(repo::find_card(db, id).await?.ok_or(AppError::NotFound)?), None => None, };
let position = match (&before, &after) { (Some(before), Some(after)) => (before.position + after.position) / 2.0, (Some(before), None) => before.position + 1.0, (None, Some(after)) => after.position - 1.0, (None, None) => 1.0, };
let updated = repo::move_card(db, card_id, target_column_id, position) .await? .ok_or(AppError::NotFound)?;
// Module 7 wires realtime broadcast of card.moved here
Ok(updated)}ไล่ลำดับทีละขั้น:
- Resolve และ authorize card ที่กำลังย้าย
repo::find_cardแล้วcard_board_idแล้วassert_member— pattern สามขั้นเดียวกับที่get_card/update_card/delete_cardกำหนดไว้แล้วใน cards - Resolve และ validate target column
columns::repo::find_columnเอาboard_idของtarget_column_idมา ถ้าไม่ตรงกับsource_board_idระบบจะปฏิเสธการย้ายด้วย403ก่อนที่อย่างอื่นจะเกิดขึ้น - Resolve เพื่อนบ้านที่เป็น optional
before_id/after_idแต่ละตัวถูก lookup ถ้ามีมา — id ของbefore/afterที่ไม่ resolve ไปยัง card จริงคือAppError::NotFoundการจัดการ “id ที่ให้มาไม่ resolve ไปอะไรเลย” แบบเดียวกับ lookup อื่น ๆ ใน module นี้ - คำนวณตำแหน่ง ด้วย
match (&before, &after)สี่ทาง — match&before/&after(reference) ไม่ใช่ movebefore/afterเพราะยังต้องอ่าน field.positionของทั้งคู่อีกครั้ง จึงไม่มีเหตุผลที่จะ consume ไปเลย - บันทึกและคืนค่า
repo::move_cardคือการเขียนหนึ่งเดียวใน function นี้ — ทุกอย่างข้างบนเป็น read หรือ check ทั้งนั้น
3. MoveCardRequest และ handler ใน cards/handlers.rs
หัวข้อที่มีชื่อว่า “3. MoveCardRequest และ handler ใน cards/handlers.rs”เพิ่ม struct และ function นี้เข้า cards/handlers.rs:
#[derive(Debug, Deserialize)]pub struct MoveCardRequest { pub target_column_id: Uuid, pub before_id: Option<Uuid>, pub after_id: Option<Uuid>,}
pub async fn move_card( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(card_id): Path<Uuid>, Json(body): Json<MoveCardRequest>,) -> AppResult<Json<Card>> { let card = service::move_card( &state, user_id, card_id, body.target_column_id, body.before_id, body.after_id, ) .await?;
Ok(Json(card))}&state ไม่ใช่ &state.db — handler เดียวใน module นี้ที่ส่ง AppState ทั้งตัวลงไปยัง function service ตรงกับ signature &AppState ของ move_card จากขั้นตอนที่ 2
4. เพิ่ม route ใน cards/mod.rs
หัวข้อที่มีชื่อว่า “4. เพิ่ม route ใน cards/mod.rs”อัปเดต cards::routes():
pub fn routes() -> Router<AppState> { Router::new() .route("/columns/:id/cards", post(handlers::create_card)) .route( "/cards/:id", get(handlers::get_card) .patch(handlers::update_card) .delete(handlers::delete_card), ) .route("/cards/:id/move", patch(handlers::move_card))}patch ต้องถูกเพิ่มเข้า import axum::routing ด้านบนไฟล์คู่กับ get และ post ถ้ายังไม่มีอยู่แล้วจาก cards
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”cargo check -p apiตั้งสอง column กับสอง card ใช้ $TOKEN และ $BOARD_ID ซ้ำ:
TODO=$(curl -s -X POST http://localhost:8080/boards/$BOARD_ID/columns \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"To Do"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
DOING=$(curl -s -X POST http://localhost:8080/boards/$BOARD_ID/columns \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Doing"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
CARD_A=$(curl -s -X POST http://localhost:8080/columns/$TODO/cards \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Card A"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
CARD_B=$(curl -s -X POST http://localhost:8080/columns/$TODO/cards \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Card B"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')$CARD_A มี position = 1.0, $CARD_B มี position = 2.0 ย้าย $CARD_B เข้า column $DOING ที่ว่างเปล่า — ไม่มี before_id/after_id การ์ดจึงไปอยู่ที่ 1.0:
curl -s -X PATCH http://localhost:8080/cards/$CARD_B/move \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d "{\"target_column_id\":\"$DOING\"}"{"id":"...","column_id":"...","title":"Card B","description":null,"position":1.0,"created_at":"..."}ย้าย $CARD_B กลับเข้า $TODO วางไว้ หลัง $CARD_A (ล่างสุดของรายการ):
curl -s -X PATCH http://localhost:8080/cards/$CARD_B/move \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d "{\"target_column_id\":\"$TODO\",\"before_id\":\"$CARD_A\"}"{"id":"...","column_id":"...","title":"Card B","description":null,"position":2.0,"created_at":"..."}สร้าง card ที่สามแล้ววางไว้ระหว่าง A กับ B:
CARD_C=$(curl -s -X POST http://localhost:8080/columns/$TODO/cards \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Card C"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
curl -s -X PATCH http://localhost:8080/cards/$CARD_C/move \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d "{\"target_column_id\":\"$TODO\",\"before_id\":\"$CARD_A\",\"after_id\":\"$CARD_B\"}"{"id":"...","column_id":"...","title":"Card C","description":null,"position":1.5,"created_at":"..."}ยืนยันว่า tree สะท้อนลำดับใหม่ — Card A (1.0), Card C (1.5), Card B (2.0):
curl -s http://localhost:8080/boards/$BOARD_ID -H "Authorization: Bearer $TOKEN" \ | python3 -c 'import json,sys; d=json.load(sys.stdin); print([c["title"] for col in d["columns"] for c in col["cards"]])'['Card A', 'Card C', 'Card B']สุดท้าย ยืนยันการปฏิเสธข้าม-board: สร้าง board ที่สองแล้วลองย้าย card เข้า column ของ board นั้น:
BOARD2=$(curl -s -X POST http://localhost:8080/boards \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Other board"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
OTHER_COLUMN=$(curl -s -X POST http://localhost:8080/boards/$BOARD2/columns \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Somewhere else"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:8080/cards/$CARD_A/move \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d "{\"target_column_id\":\"$OTHER_COLUMN\"}"403คุณเพิ่ม move_card — endpoint ที่ทั้ง REST API module กำลังนำไปสู่ — เข้า cards/service.rs, cards/handlers.rs, และ cards/mod.rs โดย function นี้ resolve และ authorize ทั้ง card และ target column ปฏิเสธการย้ายข้าม-board ด้วย 403 ก่อนที่การเขียนใด ๆ จะเกิด resolve เพื่อนบ้าน before/after แบบ optional แล้วใช้กฎ fractional-position เป๊ะที่ indexes-ordering กำหนดไว้สอง module ก่อนหน้านี้: หาค่าเฉลี่ยของเพื่อนบ้านสองข้าง, ก้าว ±1.0 ผ่านปลายข้างใดข้างหนึ่ง, หรือ 1.0 เข้า column ว่าง move_card รับ &AppState แทน &PgPool — ความต่างของ signature ที่ตั้งใจหนึ่งเดียวในทั้ง module นี้ — เพื่อให้ Module 7 เพิ่ม broadcast card.moved ที่ comment ที่ทำเครื่องหมายไว้ได้ โดยไม่ต้องเปลี่ยน signature ของ function นี้หรือจุดเรียกในฝั่ง handler เลย นั่นคือการปิด Module 5: REST API ของ TaskFlow ตอนนี้มี CRUD เต็มรูปแบบครอบคลุม boards, columns, cards, และ labels, authorization ตาม membership ในทุก endpoint, และการกระทำเดียว — การจัดลำดับใหม่ — ที่การออกแบบฐานข้อมูลทั้งหมดใน Module 2 ถูกสร้างมาเพื่อรองรับอย่างถูก ถัดไป Caching เอา Redis มาใช้เร่งความเร็วการอ่านที่ module นี้เพิ่งสร้างขึ้นมา