diff --git a/internal/httpapi/handlers_projects.go b/internal/httpapi/handlers_projects.go index fbcef63..1d46a0e 100644 --- a/internal/httpapi/handlers_projects.go +++ b/internal/httpapi/handlers_projects.go @@ -212,6 +212,9 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { } s.db.Create(&p) s.audit(r, "create", "project", p.ID, p.Name) + if p.PMEmail != "" && !s.owns(r, p.PMEmail) { + s.notify(p.PMEmail, "project", "프로젝트 PM으로 지정되었습니다", p.Name, "/projects/"+p.ID) + } writeJSON(w, http.StatusCreated, p) } @@ -224,6 +227,7 @@ func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "프로젝트를 찾을 수 없습니다") return } + oldPM := p.PMEmail var patch map[string]interface{} if err := decodeJSON(r, &patch); err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -238,6 +242,9 @@ func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) { return } s.db.First(&p, "id = ?", p.ID) + if p.PMEmail != "" && !strings.EqualFold(p.PMEmail, oldPM) && !s.owns(r, p.PMEmail) { + s.notify(p.PMEmail, "project", "프로젝트 PM으로 지정되었습니다", p.Name, "/projects/"+p.ID) + } writeJSON(w, http.StatusOK, p) } @@ -383,9 +390,21 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { t.Lane = "todo" } s.db.Create(&t) + // 담당자로 배정되면 본인에게 알림(+모바일 푸시). 본인이 본인에게 단 경우는 제외. + if t.Assignee != "" && !s.owns(r, t.Assignee) { + s.notify(t.Assignee, "task", "작업에 배정되었습니다", + fmt.Sprintf("[%s] %s", s.projectName(id), t.Title), "/projects/"+id) + } writeJSON(w, http.StatusCreated, t) } +// projectName returns a project's name (best-effort) for notification text. +func (s *Server) projectName(id string) string { + var p models.Project + s.db.Select("name").First(&p, "id = ?", id) + return p.Name +} + func (s *Server) handlePatchTask(w http.ResponseWriter, r *http.Request) { var t models.ProjectTask if err := s.db.First(&t, "id = ?", chi.URLParam(r, "tId")).Error; err != nil { @@ -396,6 +415,7 @@ func (s *Server) handlePatchTask(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "권한이 없습니다") return } + oldAssignee := t.Assignee var patch map[string]interface{} if err := decodeJSON(r, &patch); err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -414,6 +434,11 @@ func (s *Server) handlePatchTask(w http.ResponseWriter, r *http.Request) { patch = snakeKeys(patch) s.db.Model(&t).Updates(patch) s.db.First(&t, "id = ?", t.ID) + // 담당자가 새 사람으로 바뀌면 그 사람에게 알림(+푸시). + if t.Assignee != "" && !strings.EqualFold(t.Assignee, oldAssignee) && !s.owns(r, t.Assignee) { + s.notify(t.Assignee, "task", "작업에 배정되었습니다", + fmt.Sprintf("[%s] %s", s.projectName(t.ProjectID), t.Title), "/projects/"+t.ProjectID) + } writeJSON(w, http.StatusOK, t) } @@ -459,6 +484,18 @@ func (s *Server) handleCreateTaskComment(w http.ResponseWriter, r *http.Request) c.TaskID = tid c.AuthorEmail = s.email(r) s.db.Create(&c) + // 댓글이 달리면 작업 담당자에게 알림(본인 댓글 제외). + var t models.ProjectTask + if s.db.Select("assignee", "title", "project_id").First(&t, "id = ?", tid).Error == nil { + if t.Assignee != "" && !s.owns(r, t.Assignee) { + body := c.Body + if len(body) > 50 { + body = body[:50] + "…" + } + s.notify(t.Assignee, "task", "내 작업에 새 댓글", + fmt.Sprintf("%s — %s", t.Title, body), "/projects/"+t.ProjectID) + } + } writeJSON(w, http.StatusCreated, c) }