feat(task): JIRA형 작업 — 설명·우선순위·라벨 필드 + 댓글 스레드
All checks were successful
build-and-push / build (push) Successful in 33s
All checks were successful
build-and-push / build (push) Successful in 33s
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
dcf8b415db
commit
f46f135dbf
@ -1,6 +1,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@ -9,6 +10,7 @@ import (
|
|||||||
"spin/internal/models"
|
"spin/internal/models"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"gorm.io/datatypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- company / product / version (master data) ----------------------------
|
// ---- company / product / version (master data) ----------------------------
|
||||||
@ -374,11 +376,78 @@ func (s *Server) handlePatchTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
delete(patch, "id")
|
delete(patch, "id")
|
||||||
delete(patch, "projectId")
|
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.Model(&t).Updates(patch)
|
||||||
s.db.First(&t, "id = ?", t.ID)
|
s.db.First(&t, "id = ?", t.ID)
|
||||||
writeJSON(w, http.StatusOK, t)
|
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) {
|
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
|
||||||
var t models.ProjectTask
|
var t models.ProjectTask
|
||||||
if err := s.db.First(&t, "id = ?", chi.URLParam(r, "tId")).Error; err != nil {
|
if err := s.db.First(&t, "id = ?", chi.URLParam(r, "tId")).Error; err != nil {
|
||||||
|
|||||||
@ -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.Post("/projects/{id}/tasks", s.handleCreateTask)
|
||||||
r.Patch("/tasks/{tId}", s.handlePatchTask)
|
r.Patch("/tasks/{tId}", s.handlePatchTask)
|
||||||
r.Delete("/tasks/{tId}", s.handleDeleteTask)
|
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
|
// admin-only commercial block
|
||||||
r.Get("/projects/{id}/contract", s.handleGetContract)
|
r.Get("/projects/{id}/contract", s.handleGetContract)
|
||||||
r.Put("/projects/{id}/contract", s.handlePutContract)
|
r.Put("/projects/{id}/contract", s.handlePutContract)
|
||||||
|
|||||||
@ -28,7 +28,7 @@ func All() []interface{} {
|
|||||||
&Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{},
|
&Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{},
|
||||||
// slice 3 — projects
|
// slice 3 — projects
|
||||||
&Company{}, &Product{}, &Version{}, &Project{}, &ProjectMember{},
|
&Company{}, &Product{}, &Version{}, &Project{}, &ProjectMember{},
|
||||||
&ClientContact{}, &ProjectTask{}, &Contract{}, &ContractFile{}, &PaymentSplit{},
|
&ClientContact{}, &ProjectTask{}, &TaskComment{}, &Contract{}, &ContractFile{}, &PaymentSplit{},
|
||||||
// slice 4 — incentive
|
// slice 4 — incentive
|
||||||
&IncentiveConfig{}, &PaymentStage{}, &UserIncentive{}, &QuarterlySettlement{},
|
&IncentiveConfig{}, &PaymentStage{}, &UserIncentive{}, &QuarterlySettlement{},
|
||||||
// slice 5 — accounting
|
// slice 5 — accounting
|
||||||
|
|||||||
@ -97,8 +97,11 @@ type ProjectTask struct {
|
|||||||
Base
|
Base
|
||||||
ProjectID string `gorm:"index" json:"projectId"`
|
ProjectID string `gorm:"index" json:"projectId"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Lane string `json:"lane"` // todo | doing | review | done
|
Description string `json:"description"` // 상세 설명 (JIRA형 카드 본문)
|
||||||
Start string `json:"start"` // YYYY-MM-DD
|
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"`
|
End string `json:"end"`
|
||||||
Assignee string `json:"assignee"`
|
Assignee string `json:"assignee"`
|
||||||
OrderIdx int `json:"orderIdx"`
|
OrderIdx int `json:"orderIdx"`
|
||||||
@ -111,6 +114,17 @@ type ProjectTask struct {
|
|||||||
|
|
||||||
func (m *ProjectTask) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
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
|
// Contract holds the [admin-only] commercial terms of a project. BE is the
|
||||||
// break-even floor (손해가 안 나는 최소 금액). Exposed ONLY to admins.
|
// break-even floor (손해가 안 나는 최소 금액). Exposed ONLY to admins.
|
||||||
type Contract struct {
|
type Contract struct {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user