package server import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "strings" "unicode/utf8" "github.com/Autumn-27/artex/db" ) const maxTaskCategoryRequestBytes = 16 << 10 // 작업 분류 API 가 사용자에게 돌려주는 오류 응답 문구. 명령·필드명(task_ids· // category_id·id)·enum 은 원문 그대로 두고, 사람이 읽는 메시지만 한국어로 둔다. const ( errTaskCatRequestTooLarge = "요청 본문이 너무 큽니다" errTaskCatNameEmpty = "분류 이름은 비워 둘 수 없습니다" errTaskCatNameTooLong = "분류 이름은 최대 80자까지 입력할 수 있습니다" errTaskCatNameConflict = "이미 존재하는 분류 이름입니다" errTaskCatInvalidID = "작업 분류 id 가 올바르지 않습니다" // errTaskCatBatchSizeFmt 는 fmt.Sprintf 로 상한을 채우는 형식 문자열이다. errTaskCatBatchSizeFmt = "task_ids 개수는 1~%d개여야 합니다" ) type taskCategoryRequest struct { Name *string `json:"name"` } func decodeTaskCategoryRequest(w http.ResponseWriter, r *http.Request) (*taskCategoryRequest, bool) { r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes) var request taskCategoryRequest if err := decode(r, &request); err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { writeErr(w, http.StatusRequestEntityTooLarge, errTaskCatRequestTooLarge) } else { writeErr(w, http.StatusBadRequest, err.Error()) } return nil, false } if request.Name == nil || strings.TrimSpace(*request.Name) == "" { writeErr(w, http.StatusBadRequest, errTaskCatNameEmpty) return nil, false } if utf8.RuneCountInString(strings.TrimSpace(*request.Name)) > db.MaxTaskCategoryNameRunes { writeErr(w, http.StatusBadRequest, errTaskCatNameTooLong) return nil, false } return &request, true } func writeTaskCategoryError(w http.ResponseWriter, err error) { switch { case errors.Is(err, db.ErrTaskCategoryInvalid): writeErr(w, http.StatusBadRequest, err.Error()) case errors.Is(err, db.ErrTaskCategoryNameConflict): writeErr(w, http.StatusConflict, errTaskCatNameConflict) case errors.Is(err, db.ErrTaskCategoryNotFound), errors.Is(err, db.ErrTaskCategoryTaskNotFound): writeErr(w, http.StatusNotFound, err.Error()) default: writeErr(w, http.StatusInternalServerError, err.Error()) } } func (s *Server) pgListTaskCategories(w http.ResponseWriter, _ *http.Request) { pg := s.pg(w) if pg == nil { return } categories, err := pg.ListTaskCategories() if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"categories": categories}) } func (s *Server) pgCreateTaskCategory(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } request, ok := decodeTaskCategoryRequest(w, r) if !ok { return } category, err := pg.CreateTaskCategory(*request.Name) if err != nil { writeTaskCategoryError(w, err) return } writeJSON(w, http.StatusCreated, category) } func (s *Server) pgRenameTaskCategory(w http.ResponseWriter, r *http.Request) { id, ok := pathInt(r, "id") if !ok { writeErr(w, http.StatusBadRequest, "bad task category id") return } request, ok := decodeTaskCategoryRequest(w, r) if !ok { return } category, err := s.m.RenameTaskCategory(id, *request.Name) if err != nil { writeTaskCategoryError(w, err) return } writeJSON(w, http.StatusOK, category) } func (s *Server) pgDeleteTaskCategory(w http.ResponseWriter, r *http.Request) { id, ok := pathInt(r, "id") if !ok { writeErr(w, http.StatusBadRequest, "bad task category id") return } deleted, err := s.m.DeleteTaskCategory(id) if err != nil { writeTaskCategoryError(w, err) return } if !deleted { writeErr(w, http.StatusNotFound, "task category not found") return } writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) } // parseCategoryIDField reads the category_id field shared by the single-task and // batch endpoints. The field is required but may be null — that clears the // category — so it arrives as RawMessage instead of *int64, which cannot tell // an explicit null from an omitted key. func parseCategoryIDField(w http.ResponseWriter, raw json.RawMessage) (*int64, bool) { if len(raw) == 0 { writeErr(w, http.StatusBadRequest, "category_id is required") return nil, false } if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return nil, true } var id int64 if err := json.Unmarshal(raw, &id); err != nil || id <= 0 { writeErr(w, http.StatusBadRequest, errTaskCatInvalidID) return nil, false } return &id, true } func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) { taskID := r.PathValue("id") if _, ok := s.m.Task(taskID); !ok { writeErr(w, http.StatusNotFound, "task not found") return } r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes) var request struct { CategoryID json.RawMessage `json:"category_id"` } if err := decode(r, &request); err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } categoryID, ok := parseCategoryIDField(w, request.CategoryID) if !ok { return } if _, err := s.m.SetTaskCategory(taskID, categoryID); err != nil { writeTaskCategoryError(w, err) return } task, _ := s.m.Task(taskID) writeJSON(w, http.StatusOK, taskDTO(task, s.resolvedTaskStatus(task))) } // batchCategoryItem reports one requested task. Unlike batch pause/resume there // is no per-task runtime outcome, so only the failure reason is carried. type batchCategoryItem struct { ID string `json:"id"` OK bool `json:"ok"` Error string `json:"error,omitempty"` } // updateTasksCategoryBatch moves every selected task into one category, or // clears it when category_id is null. The database write is atomic; per-task // entries only distinguish ids that never resolved to a live task, so a partial // success here means those tasks were deleted, not that the move half-applied. func (s *Server) updateTasksCategoryBatch(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes) var request struct { TaskIDs []string `json:"task_ids"` CategoryID json.RawMessage `json:"category_id"` } if err := decode(r, &request); err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { writeErr(w, http.StatusRequestEntityTooLarge, errTaskCatRequestTooLarge) } else { writeErr(w, http.StatusBadRequest, err.Error()) } return } categoryID, ok := parseCategoryIDField(w, request.CategoryID) if !ok { return } taskIDs := normalizeBatchTaskIDs(request.TaskIDs) if len(taskIDs) == 0 || len(taskIDs) > db.MaxTaskCategoryBatchSize { writeErr(w, http.StatusBadRequest, fmt.Sprintf(errTaskCatBatchSizeFmt, db.MaxTaskCategoryBatchSize)) return } items := make([]batchCategoryItem, 0, len(taskIDs)) pending := make([]string, 0, len(taskIDs)) for _, parsed := range taskIDs { item := batchCategoryItem{ID: parsed.id} switch { case !parsed.valid: item.Error = "bad task id" default: if _, live := s.m.Task(parsed.id); live { pending = append(pending, parsed.id) } else { item.Error = "task not found" } } items = append(items, item) } var category *db.TaskCategory if len(pending) > 0 { updated, moved, err := s.m.SetTasksCategory(pending, categoryID) if err != nil { writeTaskCategoryError(w, err) return } category = moved for i := range items { if items[i].Error != "" { continue } if updated[items[i].ID] { items[i].OK = true } else { items[i].Error = "task not found" } } } writeJSON(w, http.StatusOK, map[string]any{"items": items, "category": category}) }