From f46f135dbf7d9556c5a9aba80fe7af3e601da10c Mon Sep 17 00:00:00 2001 From: theorose49 Date: Tue, 30 Jun 2026 09:08:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(task):=20JIRA=ED=98=95=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=E2=80=94=20=EC=84=A4=EB=AA=85=C2=B7=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=EC=88=9C=EC=9C=84=C2=B7=EB=9D=BC=EB=B2=A8=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20+=20=EB=8C=93=EA=B8=80=20=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProjectTask에 description/priority/labels 추가 - TaskComment 모델 + 엔드포인트(GET/POST /tasks/{id}/comments, DELETE /comments/{id}) - 댓글 작성자=요청자, 삭제는 작성자 또는 관리자 - PATCH /tasks: labels/dependsOn JSON 슬라이스 map 패치 처리 Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/httpapi/handlers_projects.go | 69 +++++++++++++++++++++++++++ internal/httpapi/router.go | 3 ++ internal/models/models.go | 2 +- internal/models/project.go | 18 ++++++- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/internal/httpapi/handlers_projects.go b/internal/httpapi/handlers_projects.go index b2e9834..2889f62 100644 --- a/internal/httpapi/handlers_projects.go +++ b/internal/httpapi/handlers_projects.go @@ -1,6 +1,7 @@ package httpapi import ( + "encoding/json" "fmt" "net/http" "strings" @@ -9,6 +10,7 @@ import ( "spin/internal/models" "github.com/go-chi/chi/v5" + "gorm.io/datatypes" ) // ---- company / product / version (master data) ---------------------------- @@ -374,11 +376,78 @@ func (s *Server) handlePatchTask(w http.ResponseWriter, r *http.Request) { } delete(patch, "id") delete(patch, "projectId") + // JSON 슬라이스 컬럼(labels/dependsOn)은 map 패치 시 driver.Valuer로 변환해 줘야 한다. + for _, k := range []string{"labels", "dependsOn"} { + if v, ok := patch[k]; ok { + if b, err := json.Marshal(v); err == nil { + patch[k] = datatypes.JSON(b) + } + } + } s.db.Model(&t).Updates(patch) s.db.First(&t, "id = ?", t.ID) writeJSON(w, http.StatusOK, t) } +// ---- task comments (JIRA형 댓글) ----------------------------------------- + +func (s *Server) taskProjectID(taskID string) (string, bool) { + var t models.ProjectTask + if err := s.db.Select("project_id").First(&t, "id = ?", taskID).Error; err != nil { + return "", false + } + return t.ProjectID, true +} + +func (s *Server) handleListTaskComments(w http.ResponseWriter, r *http.Request) { + tid := chi.URLParam(r, "tId") + pid, ok := s.taskProjectID(tid) + if !ok || !s.canSeeProject(r, pid) { + writeError(w, http.StatusForbidden, "권한이 없습니다") + return + } + var out []models.TaskComment + s.db.Where("task_id = ?", tid).Order("created_at asc").Find(&out) + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleCreateTaskComment(w http.ResponseWriter, r *http.Request) { + tid := chi.URLParam(r, "tId") + pid, ok := s.taskProjectID(tid) + if !ok || !s.canSeeProject(r, pid) { + writeError(w, http.StatusForbidden, "권한이 없습니다") + return + } + var c models.TaskComment + if err := decodeJSON(r, &c); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if strings.TrimSpace(c.Body) == "" { + writeError(w, http.StatusBadRequest, "내용을 입력하세요") + return + } + c.ID = "" + c.TaskID = tid + c.AuthorEmail = s.email(r) + s.db.Create(&c) + writeJSON(w, http.StatusCreated, c) +} + +func (s *Server) handleDeleteTaskComment(w http.ResponseWriter, r *http.Request) { + var c models.TaskComment + if err := s.db.First(&c, "id = ?", chi.URLParam(r, "cId")).Error; err != nil { + writeError(w, http.StatusNotFound, "댓글을 찾을 수 없습니다") + return + } + if !s.isAdmin(r) && !s.owns(r, c.AuthorEmail) { + writeError(w, http.StatusForbidden, "본인 댓글만 삭제할 수 있습니다") + return + } + s.db.Delete(&models.TaskComment{}, "id = ?", c.ID) + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) { var t models.ProjectTask if err := s.db.First(&t, "id = ?", chi.URLParam(r, "tId")).Error; err != nil { diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 143750d..fda6fdc 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -123,6 +123,9 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *p r.Post("/projects/{id}/tasks", s.handleCreateTask) r.Patch("/tasks/{tId}", s.handlePatchTask) r.Delete("/tasks/{tId}", s.handleDeleteTask) + r.Get("/tasks/{tId}/comments", s.handleListTaskComments) + r.Post("/tasks/{tId}/comments", s.handleCreateTaskComment) + r.Delete("/comments/{cId}", s.handleDeleteTaskComment) // admin-only commercial block r.Get("/projects/{id}/contract", s.handleGetContract) r.Put("/projects/{id}/contract", s.handlePutContract) diff --git a/internal/models/models.go b/internal/models/models.go index 0b85b2e..146cc7d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -28,7 +28,7 @@ func All() []interface{} { &Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{}, // slice 3 — projects &Company{}, &Product{}, &Version{}, &Project{}, &ProjectMember{}, - &ClientContact{}, &ProjectTask{}, &Contract{}, &ContractFile{}, &PaymentSplit{}, + &ClientContact{}, &ProjectTask{}, &TaskComment{}, &Contract{}, &ContractFile{}, &PaymentSplit{}, // slice 4 — incentive &IncentiveConfig{}, &PaymentStage{}, &UserIncentive{}, &QuarterlySettlement{}, // slice 5 — accounting diff --git a/internal/models/project.go b/internal/models/project.go index e13b00c..67d4d5e 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -97,8 +97,11 @@ type ProjectTask struct { Base ProjectID string `gorm:"index" json:"projectId"` Title string `json:"title"` - Lane string `json:"lane"` // todo | doing | review | done - Start string `json:"start"` // YYYY-MM-DD + Description string `json:"description"` // 상세 설명 (JIRA형 카드 본문) + Lane string `json:"lane"` // todo | doing | review | done + Priority string `json:"priority"` // low | medium | high | urgent + Labels datatypes.JSONSlice[string] `json:"labels"` // 라벨/태그 + Start string `json:"start"` // YYYY-MM-DD End string `json:"end"` Assignee string `json:"assignee"` OrderIdx int `json:"orderIdx"` @@ -111,6 +114,17 @@ type ProjectTask struct { func (m *ProjectTask) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil } +// TaskComment is a JIRA-style comment thread on a task (작성자·본문·시각). +type TaskComment struct { + Base + TaskID string `gorm:"index" json:"taskId"` + AuthorEmail string `json:"authorEmail"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` +} + +func (m *TaskComment) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil } + // Contract holds the [admin-only] commercial terms of a project. BE is the // break-even floor (손해가 안 나는 최소 금액). Exposed ONLY to admins. type Contract struct {