feat(perm): 일반 구성원도 업체담당자·주의사항·작업 CRUD 허용 (비민감 정보)
All checks were successful
build-and-push / build (push) Successful in 33s

- 업체 담당자(ClientContact) upsert/delete를 requireAdmin → canSeeProject
- PATCH /projects/{id}/notes: 구성원이 주의사항·계약범위(글/그림)만 편집(화이트리스트)
- 작업(타임라인)은 기존대로 구성원 CRUD(삭제 포함, 백엔드 이미 허용)
- 유지(관리자 전용): 프로젝트 핵심정보(상태/PM/일정/업체) 수정, 작업자 기여도, 계약/정산

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 13:50:12 +09:00
parent dce6bb215e
commit 7e1bb6606e
2 changed files with 54 additions and 4 deletions

View File

@ -335,8 +335,11 @@ func (s *Server) handleListContacts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// 업체 담당자는 협업 정보라 프로젝트 구성원이면 누구나 CRUD 가능(관리자 전용 아님).
func (s *Server) handleUpsertContact(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var c models.ClientContact
@ -344,7 +347,7 @@ func (s *Server) handleUpsertContact(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
c.ProjectID = chi.URLParam(r, "id")
c.ProjectID = id
if c.ID != "" {
s.db.Save(&c)
} else {
@ -354,13 +357,59 @@ func (s *Server) handleUpsertContact(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleDeleteContact(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
var c models.ClientContact
if err := s.db.First(&c, "id = ?", chi.URLParam(r, "cId")).Error; err != nil {
writeError(w, http.StatusNotFound, "담당자를 찾을 수 없습니다")
return
}
s.db.Delete(&models.ClientContact{}, "id = ?", chi.URLParam(r, "cId"))
if !s.canSeeProject(r, c.ProjectID) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
s.db.Delete(&models.ClientContact{}, "id = ?", c.ID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// handlePatchProjectNotes lets any project member edit NON-sensitive descriptive
// fields (주의사항·계약범위 글/그림). 핵심정보(상태·PM·일정·업체)와 계약/정산은
// 여전히 관리자 전용(handlePatchProject).
func (s *Server) handlePatchProjectNotes(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var in struct {
Cautions *string `json:"cautions"`
ScopeText *string `json:"scopeText"`
ScopeGraphic *string `json:"scopeGraphic"`
}
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
upd := map[string]interface{}{}
if in.Cautions != nil {
upd["cautions"] = *in.Cautions
}
if in.ScopeText != nil {
upd["scope_text"] = *in.ScopeText
}
if in.ScopeGraphic != nil {
upd["scope_graphic"] = *in.ScopeGraphic
}
var p models.Project
if err := s.db.First(&p, "id = ?", id).Error; err != nil {
writeError(w, http.StatusNotFound, "프로젝트를 찾을 수 없습니다")
return
}
if len(upd) > 0 {
s.db.Model(&p).Updates(upd)
s.db.First(&p, "id = ?", id)
}
writeJSON(w, http.StatusOK, p)
}
// ---- tasks (gantt / kanban) ----------------------------------------------
func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {

View File

@ -122,6 +122,7 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *p
r.Get("/projects/{id}/contacts", s.handleListContacts)
r.Post("/projects/{id}/contacts", s.handleUpsertContact)
r.Delete("/contacts/{cId}", s.handleDeleteContact)
r.Patch("/projects/{id}/notes", s.handlePatchProjectNotes)
r.Get("/projects/{id}/tasks", s.handleListTasks)
r.Post("/projects/{id}/tasks", s.handleCreateTask)
r.Patch("/tasks/{tId}", s.handlePatchTask)