feat(notify): 작업 배정·댓글·PM 지정 시 대상 유저에게 쪽지함 알림(+모바일 푸시)
All checks were successful
build-and-push / build (push) Successful in 32s

- 작업 생성/수정으로 담당자 배정 시 그 구성원에게 알림(type=task), 본인배정 제외
- 내 작업에 새 댓글 달리면 담당자에게 알림(본인 댓글 제외)
- 프로젝트 PM 지정/변경 시 PM에게 알림
- 모두 기존 s.notify() 사용 → Notification 생성 + 등록기기로 FCM 푸시(자격증명 시 자동)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 13:19:26 +09:00
parent c865baccd2
commit dce6bb215e

View File

@ -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)
}