feat(mail): 프로젝트 고객사 도메인 메일 연동(Google Workspace 도메인위임) + 공동 메모
build-and-push / build (push) Successful in 32s
build-and-push / build (push) Successful in 32s
- Project.ClientDomain 필드, MailNote(프로젝트 구성원 공동 메모) 모델
- internal/mailsync: 서비스계정+도메인위임으로 팀 메일함을 도메인 검색·집계(stdlib만, push 패턴 재사용)
· GOOGLE_SA_CREDENTIALS_FILE 미설정 시 비활성(graceful)
- GET /projects/{id}/mails (3분 캐시), GET/PUT /projects/{id}/mail-notes
- fix: handlePatchProject map-key Updates가 camelCase 멀티워드 필드(consultingType·
scopeText·pmEmail·clientDomain·날짜)를 컬럼에 못 맞춰 저장 실패하던 버그 → snakeKeys 변환
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ce07aced0f
commit
751aa8ed97
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"spin/internal/mailsync"
|
||||
"spin/internal/models"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -228,6 +229,9 @@ func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
delete(patch, "id")
|
||||
// JSON 키(camelCase)를 DB 컬럼(snake_case)으로 변환해야 멀티워드 필드
|
||||
// (consultingType·scopeText·pmEmail·clientDomain·startDate 등)가 저장된다.
|
||||
patch = snakeKeys(patch)
|
||||
if err := s.db.Model(&p).Updates(patch).Error; err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -236,6 +240,28 @@ func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// snakeKeys converts camelCase JSON map keys to snake_case DB column names so
|
||||
// GORM Updates(map) targets the right columns. Safe for camelCase keys (no
|
||||
// leading/consecutive capitals), which is how all our JSON tags are written.
|
||||
func snakeKeys(m map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
var b strings.Builder
|
||||
for i, r := range k {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
if i > 0 {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
b.WriteRune(r - 'A' + 'a')
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out[b.String()] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
@@ -384,6 +410,7 @@ 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)
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
@@ -448,6 +475,118 @@ func (s *Server) handleDeleteTaskComment(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- project mail (Google Workspace 도메인 위임) + 공동 메모 -----------------
|
||||
|
||||
type mailCacheEntry struct {
|
||||
at time.Time
|
||||
msgs []mailsync.Message
|
||||
}
|
||||
|
||||
const mailCacheTTL = 3 * time.Minute
|
||||
|
||||
// handleListProjectMails aggregates client-domain mail across the project team's
|
||||
// mailboxes (service account + domain-wide delegation). Disabled/empty-domain
|
||||
// projects return an empty list with flags so the UI can explain.
|
||||
func (s *Server) handleListProjectMails(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if !s.canSeeProject(r, id) {
|
||||
writeError(w, http.StatusForbidden, "권한이 없습니다")
|
||||
return
|
||||
}
|
||||
var p models.Project
|
||||
if err := s.db.First(&p, "id = ?", id).Error; err != nil {
|
||||
writeError(w, http.StatusNotFound, "프로젝트를 찾을 수 없습니다")
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"enabled": s.mail.Enabled(),
|
||||
"domain": p.ClientDomain,
|
||||
"messages": []mailsync.Message{},
|
||||
}
|
||||
if !s.mail.Enabled() || strings.TrimSpace(p.ClientDomain) == "" {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
if v, ok := s.mailCache.Load(id); ok {
|
||||
if e := v.(*mailCacheEntry); time.Since(e.at) < mailCacheTTL {
|
||||
resp["messages"] = e.msgs
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
}
|
||||
// impersonation set = project members + PM (the team that corresponds with the client)
|
||||
var pms []models.ProjectMember
|
||||
s.db.Where("project_id = ?", id).Find(&pms)
|
||||
set := map[string]bool{}
|
||||
for _, m := range pms {
|
||||
if m.MemberEmail != "" {
|
||||
set[m.MemberEmail] = true
|
||||
}
|
||||
}
|
||||
if p.PMEmail != "" {
|
||||
set[p.PMEmail] = true
|
||||
}
|
||||
mailboxes := make([]string, 0, len(set))
|
||||
for e := range set {
|
||||
mailboxes = append(mailboxes, e)
|
||||
}
|
||||
msgs, err := s.mail.ListForDomain(r.Context(), mailboxes, p.ClientDomain, 60)
|
||||
if err != nil {
|
||||
resp["error"] = err.Error()
|
||||
}
|
||||
if msgs == nil {
|
||||
msgs = []mailsync.Message{}
|
||||
}
|
||||
resp["messages"] = msgs
|
||||
s.mailCache.Store(id, &mailCacheEntry{at: time.Now(), msgs: msgs})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleListMailNotes(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if !s.canSeeProject(r, id) {
|
||||
writeError(w, http.StatusForbidden, "권한이 없습니다")
|
||||
return
|
||||
}
|
||||
var out []models.MailNote
|
||||
s.db.Where("project_id = ?", id).Find(&out)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handlePutMailNote upserts the shared memo for one email. Any project member may
|
||||
// edit it; the message id travels in the body (Message-ID contains <>@ chars).
|
||||
func (s *Server) handlePutMailNote(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if !s.canSeeProject(r, id) {
|
||||
writeError(w, http.StatusForbidden, "권한이 없습니다")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
MessageID string `json:"messageId"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(in.MessageID) == "" {
|
||||
writeError(w, http.StatusBadRequest, "messageId가 필요합니다")
|
||||
return
|
||||
}
|
||||
var note models.MailNote
|
||||
err := s.db.Where("project_id = ? AND message_id = ?", id, in.MessageID).First(¬e).Error
|
||||
note.Body = in.Body
|
||||
note.LastEditedBy = s.email(r)
|
||||
if err != nil { // not found → create
|
||||
note.ProjectID = id
|
||||
note.MessageID = in.MessageID
|
||||
s.db.Create(¬e)
|
||||
} else {
|
||||
s.db.Model(¬e).Updates(map[string]interface{}{"body": note.Body, "last_edited_by": note.LastEditedBy})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, note)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
var t models.ProjectTask
|
||||
if err := s.db.First(&t, "id = ?", chi.URLParam(r, "tId")).Error; err != nil {
|
||||
|
||||
@@ -3,8 +3,10 @@ package httpapi
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"spin/internal/config"
|
||||
"spin/internal/mailsync"
|
||||
"spin/internal/push"
|
||||
"spin/internal/storage"
|
||||
|
||||
@@ -16,15 +18,17 @@ import (
|
||||
|
||||
// Server bundles dependencies shared by all handlers.
|
||||
type Server struct {
|
||||
db *gorm.DB
|
||||
store *storage.Storage
|
||||
cfg config.Config
|
||||
push *push.Sender
|
||||
db *gorm.DB
|
||||
store *storage.Storage
|
||||
cfg config.Config
|
||||
push *push.Sender
|
||||
mail *mailsync.Service
|
||||
mailCache sync.Map // projectID -> *mailCacheEntry (short-TTL aggregated mail)
|
||||
}
|
||||
|
||||
// NewRouter wires up the chi router and mounts the /api routes for every module.
|
||||
func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *push.Sender) http.Handler {
|
||||
s := &Server{db: db, store: store, cfg: cfg, push: pusher}
|
||||
func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *push.Sender, mailer *mailsync.Service) http.Handler {
|
||||
s := &Server{db: db, store: store, cfg: cfg, push: pusher, mail: mailer}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
@@ -127,6 +131,10 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *p
|
||||
r.Get("/tasks/{tId}/comments", s.handleListTaskComments)
|
||||
r.Post("/tasks/{tId}/comments", s.handleCreateTaskComment)
|
||||
r.Delete("/comments/{cId}", s.handleDeleteTaskComment)
|
||||
// project mail (Google Workspace 도메인 위임) + 공동 메모
|
||||
r.Get("/projects/{id}/mails", s.handleListProjectMails)
|
||||
r.Get("/projects/{id}/mail-notes", s.handleListMailNotes)
|
||||
r.Put("/projects/{id}/mail-notes", s.handlePutMailNote)
|
||||
// admin-only commercial block
|
||||
r.Get("/projects/{id}/contract", s.handleGetContract)
|
||||
r.Put("/projects/{id}/contract", s.handlePutContract)
|
||||
|
||||
Reference in New Issue
Block a user