package httpapi import ( "context" "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) 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) } 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) // 담당자로 배정되면 본인에게 알림(+모바일 푸시). 본인이 본인에게 단 경우는 제외. 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"` // 메모 마지막 수정자 } // 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 } // 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) items := make([]mailItem, 0, len(rows)) for _, row := range rows { m := mailsync.Message{ID: row.MessageID, From: row.FromAddr, To: row.ToAddr, Cc: row.CcAddr} if !m.Involves(me) { continue } it := mailItem{ProjectMailMsg: row} 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) } // 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(¬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