Cards
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”module cards: model.rs, repo.rs, service.rs, handlers.rs, mod.rs ใต้ taskflow/backend/api/src/cards/ สี่ endpoint — สร้าง card ใน column, ดึง card ตัวเดียว, patch title และ/หรือ description, ลบทิ้ง (move การกระทำที่ห้าและน่าสนใจที่สุดของ card มีบทของตัวเองแยกไป: move-reorder)
เหมือน columns/model.rs ก่อนหน้า cards/model.rs เป็น re-export บรรทัดเดียวของ Card จาก boards::model ที่เหลือทุกอย่างที่นี่ใหม่หมด: cards::service คือ module แรกที่การ resolve “นี่เป็นของ board ไหน” ต้องกระโดดสองครั้งแทนที่จะเป็นครั้งเดียว — board ของ card มาจาก board_id ของ column ที่ card ใบนั้นอยู่ ไม่ใช่คอลัมน์ board_id บน cards เอง (ไม่มีคอลัมน์นั้น — เช็ค schema ใน schema ได้)
cards::service::card_board_id — helper เล็ก ๆ แบบ private ที่หา column ของ card แล้วอ่าน board_id ของ column นั้น — มีอยู่เพราะ cards ไม่มี foreign key ตรงไปที่ boards เลย นี่ไม่ใช่ช่องว่างที่ต้องหาทางแก้ แต่คือ schema ที่ซื่อสัตย์ต่อลำดับชั้นจริง: boards → columns → cards
handler ทุกตัวในบทนี้ที่ไม่ใช่ create_card (ซึ่งมี column_id จาก URL อยู่แล้ว) ต้อง resolve สองชั้นนี้ก่อนจะเรียก assert_member ได้ การรวบไว้ใน function เล็ก ๆ ตัวเดียวทำให้ get_card, update_card และ delete_card แต่ละตัวเรียก helper นี้ครั้งเดียว แทนที่จะ inline การ lookup ชุดเดิมซ้ำสามรอบ
pattern COALESCE($2, title) ของ update_card คือจุดเดียวในบทนี้ที่มีการตัดสินใจ trade-off ขอบเขตจริง ๆ และมีการบันทึกไว้: request PATCH อัปเดต title, description, ทั้งคู่ หรือไม่มีอันไหนเลยได้ แต่ไม่มีวัน เคลียร์ description กลับเป็น NULL ได้เมื่อตั้งค่าไปแล้ว ครอบคลุมใน Pros & cons ด้านล่าง — เป็นการตัดสินใจเรื่องความง่ายอย่างตั้งใจ ไม่ใช่อุบัติเหตุ
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”UpdateCardRequest { title: Option<String>, description: Option<String> } พร้อม COALESCE (ตัวที่เราใช้) เทียบกับ double-Option (Option<Option<String>>) ที่แยก “ไม่ได้ส่งมา” ออกจาก “ตั้งใจส่ง null มา”
- ข้อดี:
UpdateCardRequestเป็น struct ธรรมดา —Deserializeเริ่มต้นของserdeจัดการได้โดยไม่ต้องเขียนโค้ดพิเศษเลยSET title = COALESCE($2, title), description = COALESCE($3, description)ของrepo::update_cardคือUPDATEเดียวอ่านง่าย ที่ปล่อย field ไหนก็ตามที่ผู้เรียกไม่ได้ส่งมาไว้เหมือนเดิม ซึ่งครอบคลุมสองรูปแบบPATCHที่พบบ่อยที่สุดถูกต้อง: “อัปเดตแค่ title” กับ “อัปเดตแค่ description” - ข้อเสีย: ไม่มีทางส่ง
PATCHที่เคลียร์descriptionเดิมกลับเป็นNULLได้เลย — ส่ง"description": nullใน JSON body จะ deserialize เป็นNoneซึ่งCOALESCEปฏิบัติเหมือนกับ “field ถูกละไว้ทั้งหมด” ทำให้ description เดิมยังอยู่ การแยกสองกรณีนี้ต้องการDeserializeแบบกำหนดเอง (มักเป็นOption<Option<T>>ห่อด้วย#[serde(default, with = "...")]หรือ enum สามสถานะ) ที่ track ว่า JSON key มีอยู่จริงไหม แยกจากค่าที่อยู่ข้างใน แต่ TaskFlow ยังไม่ต้องใช้ เพราะไม่มีที่ไหนใน frontend module ที่เคลียร์ description ให้ว่างเปล่า — เราเลยยอมรับรูปแบบที่ง่ายกว่า และบันทึกไว้ชัดเจนว่าขอบเขตอยู่ตรงไหน แทนที่จะสร้าง mechanism ทั่วไปล่วงหน้า
cards::service::card_board_id resolve column ใหม่ทุกครั้งที่เรียก (ตัวที่เราใช้) เทียบกับ cards::repo::find_card join columns ตรง ๆ เพื่อคืน board_id มาพร้อม card
- ข้อดี:
Card(จากboards::model) มี column ตรงตามที่ tablecardsมีเป๊ะ — ไม่มี fieldboard_idเพิ่มที่มีอยู่แค่เพราะความต้องการ authorization ทำให้ struct เป็นภาพสะท้อน 1:1 ที่ซื่อสัตย์ต่อ table ที่sqlx::FromRowอ่านมาcard_board_idเป็น function แยกที่ชื่อชัดเจนว่า “สำหรับ authorization” ผู้อ่านมองเห็นได้ แทนที่จะเป็น fieldboard_idบนCardเองที่เข้าใจผิดว่าเป็น schema จริงได้ง่าย - ข้อเสีย:
get_card,update_card, และdelete_cardแต่ละตัวรัน query เพิ่มสองครั้ง (หา card แล้วหา column ที่ card อยู่) ก่อนจะทำงานจริง —JOINในfind_cardคืน card พร้อมboard_idในคิวรีเดียวได้ นี่คือ trade-off round-trip-กับ-ความชัดเจนเดียวกับที่ columns ตัดสินใจไปแล้วสำหรับfind_column-แล้ว-assert_memberเพียงแค่ใช้ลึกลงไปอีกชั้นในลำดับชั้น ที่ขนาดของ TaskFlow ทั้งสอง query เพิ่มรวมกันยังต่ำกว่าหนึ่งมิลลิวินาทีมากเมื่อเทียบกับ indexed primary-key lookup
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”1. cards/model.rs
หัวข้อที่มีชื่อว่า “1. cards/model.rs”pub use crate::boards::model::Card;2. cards/repo.rs
หัวข้อที่มีชื่อว่า “2. cards/repo.rs”use sqlx::PgPool;use uuid::Uuid;
use crate::error::AppResult;
use super::model::Card;
pub async fn find_card(db: &PgPool, id: Uuid) -> AppResult<Option<Card>> { let card = sqlx::query_as::<_, Card>( "SELECT id, column_id, title, description, position, created_at FROM cards WHERE id = $1", ) .bind(id) .fetch_optional(db) .await?;
Ok(card)}
pub async fn max_position(db: &PgPool, column_id: Uuid) -> AppResult<Option<f64>> { let max: Option<f64> = sqlx::query_scalar("SELECT MAX(position) FROM cards WHERE column_id = $1") .bind(column_id) .fetch_one(db) .await?;
Ok(max)}
pub async fn insert_card( db: &PgPool, id: Uuid, column_id: Uuid, title: &str, description: Option<&str>, position: f64,) -> AppResult<Card> { let card = sqlx::query_as::<_, Card>( "INSERT INTO cards (id, column_id, title, description, position) VALUES ($1, $2, $3, $4, $5) RETURNING id, column_id, title, description, position, created_at", ) .bind(id) .bind(column_id) .bind(title) .bind(description) .bind(position) .fetch_one(db) .await?;
Ok(card)}
pub async fn update_card( db: &PgPool, id: Uuid, title: Option<&str>, description: Option<&str>,) -> AppResult<Option<Card>> { let card = sqlx::query_as::<_, Card>( "UPDATE cards SET title = COALESCE($2, title), description = COALESCE($3, description) WHERE id = $1 RETURNING id, column_id, title, description, position, created_at", ) .bind(id) .bind(title) .bind(description) .fetch_optional(db) .await?;
Ok(card)}
pub async fn delete_card(db: &PgPool, id: Uuid) -> AppResult<bool> { let result = sqlx::query("DELETE FROM cards WHERE id = $1") .bind(id) .execute(db) .await?;
Ok(result.rows_affected() > 0)}description: Option<&str> ของ insert_card bind เข้า sqlx ตรง ๆ — None กลายเป็น SQL NULL ใน INSERT ตรงกับคอลัมน์ nullable cards.description จาก schema ส่วน function move_card ของ repo เองอธิบายในบทถัดไป move-reorder — แม้จะอยู่ในไฟล์เดียวกันนี้ แต่จะแนะนำที่บทนั้น คู่กับเลขคณิต position ที่รองรับกันอยู่
3. cards/service.rs
หัวข้อที่มีชื่อว่า “3. cards/service.rs”use sqlx::PgPool;use uuid::Uuid;
use crate::{ boards::service as boards_service, columns, error::{AppError, AppResult},};
use super::model::Card;use super::repo;
pub async fn create_card( db: &PgPool, user_id: Uuid, column_id: Uuid, title: String, description: Option<String>,) -> AppResult<Card> { let column = columns::repo::find_column(db, column_id) .await? .ok_or(AppError::NotFound)?; boards_service::assert_member(db, user_id, column.board_id).await?;
let position = repo::max_position(db, column_id).await?.unwrap_or(0.0) + 1.0; repo::insert_card( db, Uuid::new_v4(), column_id, &title, description.as_deref(), position, ) .await}
async fn card_board_id(db: &PgPool, card: &Card) -> AppResult<Uuid> { let column = columns::repo::find_column(db, card.column_id) .await? .ok_or(AppError::NotFound)?; Ok(column.board_id)}
pub async fn get_card(db: &PgPool, user_id: Uuid, card_id: Uuid) -> AppResult<Card> { let card = repo::find_card(db, card_id) .await? .ok_or(AppError::NotFound)?; let board_id = card_board_id(db, &card).await?; boards_service::assert_member(db, user_id, board_id).await?;
Ok(card)}
pub async fn update_card( db: &PgPool, user_id: Uuid, card_id: Uuid, title: Option<String>, description: Option<String>,) -> AppResult<Card> { let card = repo::find_card(db, card_id) .await? .ok_or(AppError::NotFound)?; let board_id = card_board_id(db, &card).await?; boards_service::assert_member(db, user_id, board_id).await?;
repo::update_card(db, card_id, title.as_deref(), description.as_deref()) .await? .ok_or(AppError::NotFound)}
pub async fn delete_card(db: &PgPool, user_id: Uuid, card_id: Uuid) -> AppResult<()> { let card = repo::find_card(db, card_id) .await? .ok_or(AppError::NotFound)?; let board_id = card_board_id(db, &card).await?; boards_service::assert_member(db, user_id, board_id).await?;
if repo::delete_card(db, card_id).await? { Ok(()) } else { Err(AppError::NotFound) }}card_board_id รับ &Card ไม่ใช่ card_id ที่ต้องไปหาเอง เพราะผู้เรียกทุกคนมี Card อยู่ในมือแล้วจาก repo::find_card ของตัวเอง card_board_id จึงใช้ค่านั้นซ้ำแทนที่จะ lookup ครั้งที่สี่ซ้ำซ้อน move_card ที่ครอบคลุมในบทถัดไปก็เรียก helper private ตัวเดียวกันนี้
4. cards/handlers.rs
หัวข้อที่มีชื่อว่า “4. cards/handlers.rs”use axum::{ extract::{Path, State}, http::StatusCode, Json,};use serde::Deserialize;use uuid::Uuid;
use crate::{auth::middleware::AuthUser, error::AppResult, state::AppState};
use super::model::Card;use super::service;
#[derive(Debug, Deserialize)]pub struct CreateCardRequest { pub title: String, pub description: Option<String>,}
#[derive(Debug, Deserialize)]pub struct UpdateCardRequest { pub title: Option<String>, pub description: Option<String>,}
pub async fn create_card( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(column_id): Path<Uuid>, Json(body): Json<CreateCardRequest>,) -> AppResult<(StatusCode, Json<Card>)> { let card = service::create_card(&state.db, user_id, column_id, body.title, body.description).await?; Ok((StatusCode::CREATED, Json(card)))}
pub async fn get_card( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(card_id): Path<Uuid>,) -> AppResult<Json<Card>> { let card = service::get_card(&state.db, user_id, card_id).await?; Ok(Json(card))}
pub async fn update_card( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(card_id): Path<Uuid>, Json(body): Json<UpdateCardRequest>,) -> AppResult<Json<Card>> { let card = service::update_card(&state.db, user_id, card_id, body.title, body.description).await?; Ok(Json(card))}
pub async fn delete_card( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(card_id): Path<Uuid>,) -> AppResult<StatusCode> { service::delete_card(&state.db, user_id, card_id).await?; Ok(StatusCode::NO_CONTENT)}handler และ DTO MoveCardRequest ของ move_card ถูกเพิ่มเข้าไฟล์เดียวกันนี้ในบทถัดไป — ตัดออกจากที่นี่เพื่อให้ไฟล์นี้ตรงกับสิ่งที่ cards::service นิยามไว้ ณ จุดนี้เป๊ะ
5. cards/mod.rs
หัวข้อที่มีชื่อว่า “5. cards/mod.rs”pub mod handlers;pub mod model;pub mod repo;pub mod service;
use axum::{ routing::{get, post}, Router,};
use crate::state::AppState;
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 เข้าร่วม chain .route() เดียวกันนี้ในบทถัดไป เมื่อ handlers::move_card มีอยู่ให้ route ไปหา
6. Mount cards::routes() ใน main.rs
หัวข้อที่มีชื่อว่า “6. Mount cards::routes() ใน main.rs”mod auth;mod boards;mod cards;mod columns;mod config;mod db;mod error;mod state;
let app = Router::new() .route("/health", get(health)) .nest("/auth", auth::routes()) .merge(boards::routes()) .merge(columns::routes()) .merge(cards::routes()) .layer(cors) .with_state(state);ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”cargo check -p apiใช้ $TOKEN, $BOARD_ID ซ้ำ และสร้าง column ใหม่ (รันขั้นตอน verify ของ columns ใหม่ถ้าคุณลบ column ทดสอบไปแล้ว):
COLUMN_ID=$(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"])')สร้าง card:
CARD_ID=$(curl -s -X POST http://localhost:8080/columns/$COLUMN_ID/cards \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Write the REST API module","description":"Boards, columns, cards, labels"}' \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')ดึง card ออกมาตรง ๆ:
curl -s http://localhost:8080/cards/$CARD_ID -H "Authorization: Bearer $TOKEN"{"id":"...","column_id":"...","title":"Write the REST API module","description":"Boards, columns, cards, labels","position":1.0,"created_at":"..."}Patch แค่ title — description ไม่ถูกแตะ ยืนยัน pattern COALESCE:
curl -s -X PATCH http://localhost:8080/cards/$CARD_ID \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Write the cards lesson"}'{"id":"...","column_id":"...","title":"Write the cards lesson","description":"Boards, columns, cards, labels","position":1.0,"created_at":"..."}ลบทิ้ง:
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE http://localhost:8080/cards/$CARD_ID \ -H "Authorization: Bearer $TOKEN"204คุณสร้าง module cards — model.rs re-export Card, query ของ repo.rs แบบ insert-at-end และ partial-update ด้วย COALESCE, helper card_board_id ใน service.rs ที่ resolve board ของ card ผ่าน column ที่ card อยู่ (การ lookup authorization สองชั้นครั้งแรกในคอร์ส), และ handlers.rs บาง ๆ สี่ตัว โดยรวม cards::routes() เข้า main.rs
คุณยังเห็นขอบเขตที่บันทึกไว้ของ pattern COALESCE ด้วย — ตั้งค่า field ได้ แต่ไม่มีวันเคลียร์กลับเป็น NULL ถัดไป move-reorder เพิ่มการกระทำที่ห้าและสุดท้ายของ card — ตัวที่ต้องการเลขคณิต fractional-position จริง ๆ ที่การออกแบบฐานข้อมูลทั้งหมดนี้ถูกสร้างขึ้นมาเพื่อรองรับ