All checks were successful
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>
777 lines
23 KiB
Go
777 lines
23 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"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)
|
|
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
|
|
}
|
|
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)
|
|
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)
|
|
}
|
|
|
|
func (s *Server) handleUpsertContact(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
var c models.ClientContact
|
|
if err := decodeJSON(r, &c); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
c.ProjectID = chi.URLParam(r, "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) {
|
|
if !s.requireAdmin(w, r) {
|
|
return
|
|
}
|
|
s.db.Delete(&models.ClientContact{}, "id = ?", chi.URLParam(r, "cId"))
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
// ---- 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)
|
|
writeJSON(w, http.StatusCreated, t)
|
|
}
|
|
|
|
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
|
|
}
|
|
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)
|
|
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)
|
|
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 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 {
|
|
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
|