fix(project): 프로젝트 삭제 캐스케이드 + 생성 검증 공백 보강 (심화 QA 발견)
All checks were successful
build-and-push / build (push) Successful in 32s

- handleDeleteProject: 작업·댓글·작업자·담당자·계약·첨부·분할입금·메일메모/메시지/상태까지
  함께 삭제(고아 레코드 방지)
- 서버측 필수값 검증 추가(프론트뿐 아니라 API도):
  · 작업 제목, 담당자 이름, 구성원 이메일 필수 → 400
  · 작업자 기여도 0~100 범위 → 400
  · 일정 제목·시작일 필수, 종료<시작 거부 → 400
  · 휴가 종류·시작일 필수, 종료<시작 거부 → 400

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-07-02 09:54:23 +09:00
parent 7e32e0f22b
commit b196f85bb3
4 changed files with 53 additions and 1 deletions

View File

@ -145,6 +145,14 @@ func (s *Server) handleCreateLeave(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if lv.Type == "" || lv.StartDate == "" {
writeError(w, http.StatusBadRequest, "휴가 종류와 시작일이 필요합니다")
return
}
if lv.EndDate != "" && lv.EndDate < lv.StartDate {
writeError(w, http.StatusBadRequest, "종료일이 시작일보다 빠릅니다")
return
}
lv.MemberEmail = s.email(r) // members can only file for themselves
lv.Status = models.StatusPending
lv.Approver = ""

View File

@ -3,6 +3,7 @@ package httpapi
import (
"encoding/json"
"net/http"
"strings"
"spin/internal/models"
@ -26,6 +27,14 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(e.Title) == "" || strings.TrimSpace(e.Start) == "" {
writeError(w, http.StatusBadRequest, "제목과 시작일이 필요합니다")
return
}
if e.End != "" && e.End < e.Start {
writeError(w, http.StatusBadRequest, "종료일이 시작일보다 빠릅니다")
return
}
e.ID = ""
e.OwnerEmail = s.email(r)
s.db.Create(&e)

View File

@ -2,6 +2,7 @@ package httpapi
import (
"net/http"
"strings"
"spin/internal/models"
@ -63,6 +64,10 @@ func (s *Server) handleCreateMember(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(m.Email) == "" {
writeError(w, http.StatusBadRequest, "이메일이 필요합니다")
return
}
if m.Role == "" {
m.Role = models.RoleMember
}

View File

@ -276,7 +276,21 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.Project{}, "id = ?", chi.URLParam(r, "id"))
id := chi.URLParam(r, "id")
// 자식 데이터까지 함께 삭제(고아 레코드 방지). task_comments는 task_id 기준이라 별도.
var taskIDs []string
s.db.Model(&models.ProjectTask{}).Where("project_id = ?", id).Pluck("id", &taskIDs)
if len(taskIDs) > 0 {
s.db.Where("task_id IN ?", taskIDs).Delete(&models.TaskComment{})
}
for _, m := range []interface{}{
&models.ProjectTask{}, &models.ProjectMember{}, &models.ClientContact{},
&models.Contract{}, &models.ContractFile{}, &models.PaymentSplit{},
&models.MailNote{}, &models.ProjectMailMsg{}, &models.ProjectMailState{},
} {
s.db.Where("project_id = ?", id).Delete(m)
}
s.db.Delete(&models.Project{}, "id = ?", id)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
@ -302,6 +316,14 @@ func (s *Server) handleUpsertProjectMember(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(pm.MemberEmail) == "" {
writeError(w, http.StatusBadRequest, "작업자를 선택하세요")
return
}
if pm.Portion < 0 || pm.Portion > 100 {
writeError(w, http.StatusBadRequest, "기여도는 0~100 사이여야 합니다")
return
}
pm.ProjectID = chi.URLParam(r, "id")
if pm.ID != "" {
s.db.Save(&pm)
@ -349,6 +371,10 @@ func (s *Server) handleUpsertContact(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(c.Name) == "" {
writeError(w, http.StatusBadRequest, "담당자 이름을 입력하세요")
return
}
c.ProjectID = id
if c.ID != "" {
s.db.Save(&c)
@ -436,6 +462,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(t.Title) == "" {
writeError(w, http.StatusBadRequest, "작업명을 입력하세요")
return
}
t.ProjectID = id
if t.Lane == "" {
t.Lane = "todo"