ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
254 lines
7.6 KiB
Go
254 lines
7.6 KiB
Go
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})
|
|
}
|