spin-backend/internal/httpapi/handlers_projects.go
theorose49 a904cbf9b9
All checks were successful
build-and-push / build (push) Successful in 33s
feat: 메일함·근무상태 기록·프로필 사진·자동 프로비저닝 + 인센티브 유저 노출 제한
- 알림(Notification) 모델/이벤트 발행(프로젝트 추가·휴가/초과근무 승인·인센티브 반영/지급·정산 확정) + 메일함 API
- 근무상태 기록(WorkStatusEvent: 출근/퇴근/휴식/미팅/이동), 출퇴근은 Attendance도 갱신
- 남은 연차(소수점) 엔드포인트, 관리자 근무관리용 집계/로그 조회
- 프로필 사진(Member.AvatarKey) 업로드/스트리밍
- Keycloak 최초 로그인 자동 Member 프로비저닝(ensureMember, rank/부서 nullable)
- 프로젝트 scope=mine(나의 업무는 관리자도 본인 참여분만), nav에 메일함·근무관리·프로젝트관리·내프로필 추가
- 운영 안전: SEED 기본값 false(로컬만 SEED=true), ADMIN_GROUPS 기본 'admin'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:38:33 +09:00

515 lines
14 KiB
Go

package httpapi
import (
"fmt"
"net/http"
"strings"
"time"
"spin/internal/models"
"github.com/go-chi/chi/v5"
)
// ---- 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)
}
// ---- 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")
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)
}
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")
s.db.Model(&t).Updates(patch)
s.db.First(&t, "id = ?", t.ID)
writeJSON(w, http.StatusOK, t)
}
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