ข้ามไปยังเนื้อหา

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 ตรงตามที่ table cards มีเป๊ะ — ไม่มี field board_id เพิ่มที่มีอยู่แค่เพราะความต้องการ authorization ทำให้ struct เป็นภาพสะท้อน 1:1 ที่ซื่อสัตย์ต่อ table ที่ sqlx::FromRow อ่านมา card_board_id เป็น function แยกที่ชื่อชัดเจนว่า “สำหรับ authorization” ผู้อ่านมองเห็นได้ แทนที่จะเป็น field board_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
pub use crate::boards::model::Card;
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 ที่รองรับกันอยู่

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 ตัวเดียวกันนี้

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 นิยามไว้ ณ จุดนี้เป๊ะ

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 ไปหา

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);
Terminal window
cargo check -p api

ใช้ $TOKEN, $BOARD_ID ซ้ำ และสร้าง column ใหม่ (รันขั้นตอน verify ของ columns ใหม่ถ้าคุณลบ column ทดสอบไปแล้ว):

Terminal window
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:

Terminal window
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 ออกมาตรง ๆ:

Terminal window
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:

Terminal window
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":"..."}

ลบทิ้ง:

Terminal window
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE http://localhost:8080/cards/$CARD_ID \
-H "Authorization: Bearer $TOKEN"
204

คุณสร้าง module cardsmodel.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 จริง ๆ ที่การออกแบบฐานข้อมูลทั้งหมดนี้ถูกสร้างขึ้นมาเพื่อรองรับ