spin-backend/internal/httpapi/handlers_projects.go
theorose49 d9ab9934c0
All checks were successful
build-and-push / build (push) Successful in 33s
feat(mail): 본문 HTML 우선(메일앱처럼) + 스레드 순번(threadIndex/Count) 제공
- GetFull: text/plain 대신 text/html 우선 → 서식 있는 본문 렌더
- handleListProjectMails: 스레드별 ts 정렬 순번 계산해 threadIndex(1=원문)/threadCount 반환

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:50:11 +09:00

1000 lines
31 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"spin/internal/mailsync"
"spin/internal/models"
"github.com/go-chi/chi/v5"
"gorm.io/datatypes"
)
// ---- company / product / version (master data) ----------------------------
func (s *Server) handleListCompanies(w http.ResponseWriter, r *http.Request) {
var out []models.Company
s.db.Order("name asc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateCompany(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var c models.Company
if err := decodeJSON(r, &c); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&c)
writeJSON(w, http.StatusCreated, c)
}
func (s *Server) handleListProducts(w http.ResponseWriter, r *http.Request) {
q := s.db.Order("name asc")
if cid := r.URL.Query().Get("companyId"); cid != "" {
q = q.Where("company_id = ?", cid)
}
var out []models.Product
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateProduct(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var p models.Product
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&p)
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) handleListVersions(w http.ResponseWriter, r *http.Request) {
q := s.db.Order("label asc")
if pid := r.URL.Query().Get("productId"); pid != "" {
q = q.Where("product_id = ?", pid)
}
var out []models.Version
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateVersion(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var v models.Version
if err := decodeJSON(r, &v); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&v)
writeJSON(w, http.StatusCreated, v)
}
// patchByID applies a whitelisted-free JSON patch to a model row (admin only).
func (s *Server) patchModel(w http.ResponseWriter, r *http.Request, dest interface{}, id string) {
if !s.requireAdmin(w, r) {
return
}
if err := s.db.First(dest, "id = ?", id).Error; err != nil {
writeError(w, http.StatusNotFound, "찾을 수 없습니다")
return
}
var patch map[string]interface{}
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
delete(patch, "id")
if err := s.db.Model(dest).Updates(patch).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.db.First(dest, "id = ?", id)
writeJSON(w, http.StatusOK, dest)
}
func (s *Server) handlePatchCompany(w http.ResponseWriter, r *http.Request) {
s.patchModel(w, r, &models.Company{}, chi.URLParam(r, "id"))
}
func (s *Server) handleDeleteCompany(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.Company{}, "id = ?", chi.URLParam(r, "id"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handlePatchProduct(w http.ResponseWriter, r *http.Request) {
s.patchModel(w, r, &models.Product{}, chi.URLParam(r, "id"))
}
func (s *Server) handleDeleteProduct(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.Product{}, "id = ?", chi.URLParam(r, "id"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handlePatchVersion(w http.ResponseWriter, r *http.Request) {
s.patchModel(w, r, &models.Version{}, chi.URLParam(r, "id"))
}
func (s *Server) handleDeleteVersion(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.Version{}, "id = ?", chi.URLParam(r, "id"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- projects -------------------------------------------------------------
// myProjectIDs returns the project IDs the caller is a member of (or PM of).
func (s *Server) myProjectIDs(email string) []string {
var ids []string
s.db.Model(&models.ProjectMember{}).Where("lower(member_email) = ?", lc(email)).
Distinct().Pluck("project_id", &ids)
var pmIDs []string
s.db.Model(&models.Project{}).Where("lower(pm_email) = ?", lc(email)).Pluck("id", &pmIDs)
return append(ids, pmIDs...)
}
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
q := s.db.Order("created_at desc")
// Non-admins always see only their own projects. Admins see all by default,
// but the "나의 업무" view passes ?scope=mine to get the same own-only list.
if !s.isAdmin(r) || r.URL.Query().Get("scope") == "mine" {
ids := s.myProjectIDs(s.email(r))
if len(ids) == 0 {
writeJSON(w, http.StatusOK, []models.Project{})
return
}
q = q.Where("id IN ?", ids)
}
if cid := r.URL.Query().Get("companyId"); cid != "" {
q = q.Where("company_id = ?", cid)
}
if st := r.URL.Query().Get("status"); st != "" {
q = q.Where("status = ?", st)
}
var out []models.Project
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// canSeeProject reports whether the caller may view a project (admin or member).
func (s *Server) canSeeProject(r *http.Request, projectID string) bool {
if s.isAdmin(r) {
return true
}
for _, id := range s.myProjectIDs(s.email(r)) {
if id == projectID {
return true
}
}
return false
}
func (s *Server) handleGetProject(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
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var p models.Project
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if p.Status == "" {
p.Status = "planned"
}
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)
}
func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var p models.Project
if err := s.db.First(&p, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
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())
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
}
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)
}
// 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
}
s.db.Delete(&models.Project{}, "id = ?", chi.URLParam(r, "id"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- project members (portion) -------------------------------------------
func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var out []models.ProjectMember
s.db.Where("project_id = ?", id).Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleUpsertProjectMember(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var pm models.ProjectMember
if err := decodeJSON(r, &pm); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
pm.ProjectID = chi.URLParam(r, "id")
if pm.ID != "" {
s.db.Save(&pm)
} else {
s.db.Create(&pm)
var proj models.Project
s.db.First(&proj, "id = ?", pm.ProjectID)
s.notify(pm.MemberEmail, "project", "프로젝트에 추가되었습니다",
fmt.Sprintf("'%s' 프로젝트에 작업자로 추가되었습니다. (기여도 %g%%)", proj.Name, pm.Portion),
"/projects/"+pm.ProjectID)
}
writeJSON(w, http.StatusOK, pm)
}
func (s *Server) handleDeleteProjectMember(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.ProjectMember{}, "id = ?", chi.URLParam(r, "pmId"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- client contacts ------------------------------------------------------
func (s *Server) handleListContacts(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var out []models.ClientContact
s.db.Where("project_id = ?", id).Find(&out)
writeJSON(w, http.StatusOK, out)
}
// 업체 담당자는 협업 정보라 프로젝트 구성원이면 누구나 CRUD 가능(관리자 전용 아님).
func (s *Server) handleUpsertContact(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var c models.ClientContact
if err := decodeJSON(r, &c); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
c.ProjectID = id
if c.ID != "" {
s.db.Save(&c)
} else {
s.db.Create(&c)
}
writeJSON(w, http.StatusOK, c)
}
func (s *Server) handleDeleteContact(w http.ResponseWriter, r *http.Request) {
var c models.ClientContact
if err := s.db.First(&c, "id = ?", chi.URLParam(r, "cId")).Error; err != nil {
writeError(w, http.StatusNotFound, "담당자를 찾을 수 없습니다")
return
}
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) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var out []models.ProjectTask
s.db.Where("project_id = ?", id).Order("order_idx asc, start asc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var t models.ProjectTask
if err := decodeJSON(r, &t); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
t.ProjectID = id
if t.Lane == "" {
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 {
writeError(w, http.StatusNotFound, "작업을 찾을 수 없습니다")
return
}
if !s.canSeeProject(r, t.ProjectID) {
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())
return
}
delete(patch, "id")
delete(patch, "projectId")
// JSON 슬라이스 컬럼(labels/dependsOn)은 map 패치 시 driver.Valuer로 변환해 줘야 한다.
for _, k := range []string{"labels", "dependsOn"} {
if v, ok := patch[k]; ok {
if b, err := json.Marshal(v); err == nil {
patch[k] = datatypes.JSON(b)
}
}
}
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)
}
// ---- task comments (JIRA형 댓글) -----------------------------------------
func (s *Server) taskProjectID(taskID string) (string, bool) {
var t models.ProjectTask
if err := s.db.Select("project_id").First(&t, "id = ?", taskID).Error; err != nil {
return "", false
}
return t.ProjectID, true
}
func (s *Server) handleListTaskComments(w http.ResponseWriter, r *http.Request) {
tid := chi.URLParam(r, "tId")
pid, ok := s.taskProjectID(tid)
if !ok || !s.canSeeProject(r, pid) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var out []models.TaskComment
s.db.Where("task_id = ?", tid).Order("created_at asc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateTaskComment(w http.ResponseWriter, r *http.Request) {
tid := chi.URLParam(r, "tId")
pid, ok := s.taskProjectID(tid)
if !ok || !s.canSeeProject(r, pid) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var c models.TaskComment
if err := decodeJSON(r, &c); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(c.Body) == "" {
writeError(w, http.StatusBadRequest, "내용을 입력하세요")
return
}
c.ID = ""
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)
}
func (s *Server) handleDeleteTaskComment(w http.ResponseWriter, r *http.Request) {
var c models.TaskComment
if err := s.db.First(&c, "id = ?", chi.URLParam(r, "cId")).Error; err != nil {
writeError(w, http.StatusNotFound, "댓글을 찾을 수 없습니다")
return
}
if !s.isAdmin(r) && !s.owns(r, c.AuthorEmail) {
writeError(w, http.StatusForbidden, "본인 댓글만 삭제할 수 있습니다")
return
}
s.db.Delete(&models.TaskComment{}, "id = ?", c.ID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- project mail (Google Workspace 도메인 위임) + 공동 메모 -----------------
type mailItem struct {
models.ProjectMailMsg
Note string `json:"note"` // 공동 메모 본문
NoteEditedBy string `json:"noteEditedBy"` // 메모 마지막 수정자
ThreadIndex int `json:"threadIndex"` // 스레드 내 순번(1=원문)
ThreadCount int `json:"threadCount"` // 스레드 총 메일 수
}
// handleListProjectMails returns the stored client-domain mail for the project,
// each with its shared memo, filtered to messages the requester is a party to
// (from/to/cc). Hidden flag travels along so the UI can collapse hidden items.
// A never-synced project triggers an async full backfill.
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": []mailItem{},
}
if strings.TrimSpace(p.ClientDomain) == "" {
writeJSON(w, http.StatusOK, resp)
return
}
var st models.ProjectMailState
synced := s.db.First(&st, "project_id = ?", id).Error == nil
if synced && st.LastSyncedAt != nil {
resp["lastSyncedAt"] = st.LastSyncedAt
}
if st.LastError != "" {
resp["error"] = st.LastError
}
if isSyncing(id) {
resp["syncing"] = true
}
// First ever view → kick off full backfill in the background (only if mail
// integration is configured). Already-stored mail is shown regardless.
if !synced && s.mail.Enabled() {
resp["syncing"] = true
go syncProjectMail(context.Background(), s.db, s.mail, id, true)
}
var rows []models.ProjectMailMsg
s.db.Where("project_id = ?", id).Order("ts desc").Find(&rows)
notes := map[string]models.MailNote{}
var nl []models.MailNote
s.db.Where("project_id = ?", id).Find(&nl)
for _, n := range nl {
notes[n.MessageID] = n
}
me := s.email(r)
// 스레드 내 순번 계산(저장된 전체 메일 기준, ts 오름차순). 1=원문.
type tpos struct{ idx, cnt int }
byThread := map[string][]models.ProjectMailMsg{}
for _, row := range rows {
byThread[row.ThreadID] = append(byThread[row.ThreadID], row)
}
pos := map[string]tpos{}
for tid, group := range byThread {
if tid == "" {
for _, g := range group {
pos[g.MessageID] = tpos{1, 1}
}
continue
}
sort.Slice(group, func(i, j int) bool { return group[i].TS < group[j].TS })
for i, g := range group {
pos[g.MessageID] = tpos{i + 1, len(group)}
}
}
// 가시성: 단일 메일 기준 — 그 메일의 from/to/cc에 내가 있으면 보인다(답장도 각각 개별 판정).
items := make([]mailItem, 0, len(rows))
for _, row := range rows {
m := mailsync.Message{From: row.FromAddr, To: row.ToAddr, Cc: row.CcAddr}
if !m.Involves(me) {
continue
}
it := mailItem{ProjectMailMsg: row, ThreadIndex: pos[row.MessageID].idx, ThreadCount: pos[row.MessageID].cnt}
if n, ok := notes[row.MessageID]; ok {
it.Note = n.Body
it.NoteEditedBy = n.LastEditedBy
}
items = append(items, it)
}
resp["messages"] = items
writeJSON(w, http.StatusOK, resp)
}
// involvedRow loads a stored mail row and confirms the requester is a party to it
// (per-mail visibility), returning the row + the mailbox to impersonate(=requester).
func (s *Server) involvedRow(w http.ResponseWriter, r *http.Request, projectID, messageID string) (models.ProjectMailMsg, bool) {
var row models.ProjectMailMsg
if err := s.db.Where("project_id = ? AND message_id = ?", projectID, messageID).First(&row).Error; err != nil {
writeError(w, http.StatusNotFound, "메일을 찾을 수 없습니다")
return row, false
}
m := mailsync.Message{From: row.FromAddr, To: row.ToAddr, Cc: row.CcAddr}
if !m.Involves(s.email(r)) {
writeError(w, http.StatusForbidden, "이 메일을 볼 권한이 없습니다")
return row, false
}
return row, true
}
// handleMailFull fetches the full body + attachment list of one mail on demand
// (impersonating the requester, who is a party to it).
func (s *Server) handleMailFull(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
msgID := r.URL.Query().Get("messageId")
if _, ok := s.involvedRow(w, r, id, msgID); !ok {
return
}
if !s.mail.Enabled() {
writeError(w, http.StatusServiceUnavailable, "메일 연동이 설정되지 않았습니다")
return
}
full, err := s.mail.GetFull(r.Context(), s.email(r), msgID)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, full)
}
// handleMailAttachment streams one attachment's bytes (impersonating the requester).
func (s *Server) handleMailAttachment(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) || !s.mail.Enabled() {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
q := r.URL.Query()
if _, ok := s.involvedRow(w, r, id, q.Get("messageId")); !ok {
return
}
data, err := s.mail.GetAttachment(r.Context(), s.email(r), q.Get("gmailMsgId"), q.Get("attachmentId"))
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
fn := q.Get("filename")
if fn == "" {
fn = "attachment"
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(fn))
w.Write(data)
}
// handleSyncProjectMail forces a full resync (async). Any project member may run it.
func (s *Server) handleSyncProjectMail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
go syncProjectMail(context.Background(), s.db, s.mail, id, true)
writeJSON(w, http.StatusAccepted, map[string]bool{"started": true})
}
// handleHideMail toggles project-level visibility of one mail (declutter). Any
// project member may hide/unhide; it applies for the whole project.
func (s *Server) handleHideMail(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"`
Hidden bool `json:"hidden"`
}
if err := decodeJSON(r, &in); err != nil || strings.TrimSpace(in.MessageID) == "" {
writeError(w, http.StatusBadRequest, "messageId가 필요합니다")
return
}
by := ""
if in.Hidden {
by = s.email(r)
}
s.db.Model(&models.ProjectMailMsg{}).
Where("project_id = ? AND message_id = ?", id, in.MessageID).
Updates(map[string]interface{}{"hidden": in.Hidden, "hidden_by": by})
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
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(&note).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(&note)
} else {
s.db.Model(&note).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 {
writeError(w, http.StatusNotFound, "작업을 찾을 수 없습니다")
return
}
if !s.isAdmin(r) && !s.canSeeProject(r, t.ProjectID) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
s.db.Delete(&models.ProjectTask{}, "id = ?", t.ID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- contract (ADMIN ONLY) ------------------------------------------------
func (s *Server) handleGetContract(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var c models.Contract
if err := s.db.First(&c, "project_id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeJSON(w, http.StatusOK, nil) // no contract yet
return
}
writeJSON(w, http.StatusOK, c)
}
func (s *Server) handlePutContract(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
pid := chi.URLParam(r, "id")
var in models.Contract
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
in.ProjectID = pid
var existing models.Contract
if err := s.db.First(&existing, "project_id = ?", pid).Error; err == nil {
in.ID = existing.ID
s.db.Save(&in)
} else {
s.db.Create(&in)
}
s.audit(r, "update", "contract", pid, "")
writeJSON(w, http.StatusOK, in)
}
// ---- contract files (ADMIN ONLY, S3) -------------------------------------
func (s *Server) handleListContractFiles(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.ContractFile
s.db.Where("project_id = ?", chi.URLParam(r, "id")).Order("created_at desc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleUploadContractFile(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
pid := chi.URLParam(r, "id")
if err := r.ParseMultipartForm(50 << 20); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "file 필드가 필요합니다")
return
}
defer file.Close()
kind := r.FormValue("kind")
if kind == "" {
kind = "contract"
}
key := fmt.Sprintf("contracts/%s/%d-%s", pid, time.Now().UnixNano(), hdr.Filename)
if s.store != nil {
if err := s.store.Upload(r.Context(), key, hdr.Header.Get("Content-Type"), file, hdr.Size); err != nil {
writeError(w, http.StatusInternalServerError, "업로드 실패: "+err.Error())
return
}
}
cf := models.ContractFile{ProjectID: pid, Kind: kind, Filename: hdr.Filename, S3Key: key,
Size: hdr.Size, UploadedBy: currentUser(r.Context()).Email}
s.db.Create(&cf)
s.audit(r, "upload", "contract_file", cf.ID, hdr.Filename)
writeJSON(w, http.StatusCreated, cf)
}
func (s *Server) handleDownloadContractFile(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var cf models.ContractFile
if err := s.db.First(&cf, "id = ?", chi.URLParam(r, "fId")).Error; err != nil {
writeError(w, http.StatusNotFound, "파일을 찾을 수 없습니다")
return
}
if s.store == nil {
writeError(w, http.StatusServiceUnavailable, "스토리지가 비활성화되어 있습니다")
return
}
url, err := s.store.PresignGet(r.Context(), cf.S3Key)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": url})
}
func (s *Server) handleDeleteContractFile(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var cf models.ContractFile
if err := s.db.First(&cf, "id = ?", chi.URLParam(r, "fId")).Error; err != nil {
writeError(w, http.StatusNotFound, "파일을 찾을 수 없습니다")
return
}
if s.store != nil {
_ = s.store.Delete(r.Context(), cf.S3Key)
}
s.db.Delete(&models.ContractFile{}, "id = ?", cf.ID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- payment splits (ADMIN ONLY) -----------------------------------------
func (s *Server) handleListPayments(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.PaymentSplit
s.db.Where("project_id = ?", chi.URLParam(r, "id")).Order("order_idx asc, expected_date asc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreatePayment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var p models.PaymentSplit
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
p.ProjectID = chi.URLParam(r, "id")
s.db.Create(&p)
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) handlePatchPayment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var p models.PaymentSplit
if err := s.db.First(&p, "id = ?", chi.URLParam(r, "payId")).Error; err != nil {
writeError(w, http.StatusNotFound, "분할 항목을 찾을 수 없습니다")
return
}
var patch map[string]interface{}
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
delete(patch, "id")
delete(patch, "projectId")
s.db.Model(&p).Updates(patch)
s.db.First(&p, "id = ?", p.ID)
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleDeletePayment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.PaymentSplit{}, "id = ?", chi.URLParam(r, "payId"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// guard against unused import when trimming
var _ = strings.TrimSpace