spin-backend/internal/httpapi/handlers_members.go
theorose49 f83724b995
All checks were successful
build-and-push / build (push) Successful in 39s
feat: spin 백엔드 전체 구현 (근무·프로젝트·인센티브·회계)
- config/db/storage/auth/router/perms: eQMS 규약 미러링, 권한 2-tier
  (관리자 전체 / 구성원 본인·신청만), oauth2-proxy 헤더 인증 + DEV_AUTH mock
- 모델: 구성원/부서, 근무(출퇴근·휴가·공가·초과), 프로젝트(회사/제품/버전·
  작업자portion·담당자·태스크·계약·첨부·분할입금), 인센티브(설정·단계·
  유저배분·분기정산), 회계(거래·세금)
- internal/worktime: 근로기준법 월 집계 엔진
- internal/incentive: BE/non-BE × 계약금/중도금/잔금 3단계 계산 + 시뮬레이션
- 시드 데이터, Go 멀티스테이지 Dockerfile
- ADMIN_GROUPS 기본값 'admin' (전 내부 앱 공통 그룹)

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

208 lines
5.6 KiB
Go

package httpapi
import (
"net/http"
"spin/internal/models"
"github.com/go-chi/chi/v5"
)
// ---- members --------------------------------------------------------------
// handleListMembers: admins see everyone; a regular member sees only themselves
// (the directory itself is admin-managed, individuals only see their own row).
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
q := s.db.Order("display_name asc")
if !s.isAdmin(r) {
q = q.Where("lower(email) = ?", s.email(r))
}
var out []models.Member
if err := q.Find(&out).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleGetMember(w http.ResponseWriter, r *http.Request) {
var m models.Member
if err := s.db.First(&m, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "구성원을 찾을 수 없습니다")
return
}
if !s.isAdmin(r) && !s.owns(r, m.Email) {
writeError(w, http.StatusForbidden, "본인 정보만 조회할 수 있습니다")
return
}
writeJSON(w, http.StatusOK, m)
}
func (s *Server) handleCreateMember(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var m models.Member
if err := decodeJSON(r, &m); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if m.Role == "" {
m.Role = models.RoleMember
}
if m.Status == "" {
m.Status = "active"
}
if err := s.db.Create(&m).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.audit(r, "create", "member", m.ID, m.Email)
writeJSON(w, http.StatusCreated, m)
}
// memberSelfPatch is the small set of fields a member may edit on their own row.
type memberSelfPatch struct {
Phone *string `json:"phone"`
Position *string `json:"position"`
}
func (s *Server) handlePatchMember(w http.ResponseWriter, r *http.Request) {
var m models.Member
if err := s.db.First(&m, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "구성원을 찾을 수 없습니다")
return
}
if s.isAdmin(r) {
var patch map[string]interface{}
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// whitelist admin-editable columns
allowed := map[string]bool{"displayName": true, "rank": true, "departmentId": true,
"role": true, "isPartner": true, "phone": true, "position": true, "status": true,
"joinDate": true, "annualLeave": true, "email": true}
cols := map[string]interface{}{}
for k, v := range patch {
if allowed[k] {
cols[colName(k)] = v
}
}
if err := s.db.Model(&m).Updates(cols).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.audit(r, "update", "member", m.ID, "")
} else {
if !s.owns(r, m.Email) {
writeError(w, http.StatusForbidden, "본인 정보만 수정할 수 있습니다")
return
}
var p memberSelfPatch
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if p.Phone != nil {
m.Phone = *p.Phone
}
if p.Position != nil {
m.Position = *p.Position
}
s.db.Save(&m)
}
s.db.First(&m, "id = ?", m.ID)
writeJSON(w, http.StatusOK, m)
}
func (s *Server) handleDeleteMember(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
id := chi.URLParam(r, "id")
if err := s.db.Delete(&models.Member{}, "id = ?", id).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.audit(r, "delete", "member", id, "")
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// colName maps a JSON field name to its snake_case DB column.
func colName(json string) string {
switch json {
case "displayName":
return "display_name"
case "departmentId":
return "department_id"
case "isPartner":
return "is_partner"
case "joinDate":
return "join_date"
case "annualLeave":
return "annual_leave"
default:
return json
}
}
// ---- departments ----------------------------------------------------------
func (s *Server) handleListDepartments(w http.ResponseWriter, r *http.Request) {
var out []models.Department
s.db.Order("name asc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateDepartment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var d models.Department
if err := decodeJSON(r, &d); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&d)
writeJSON(w, http.StatusCreated, d)
}
func (s *Server) handlePatchDepartment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var d models.Department
if err := s.db.First(&d, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "부서를 찾을 수 없습니다")
return
}
var patch models.Department
decodeJSON(r, &patch)
if patch.Name != "" {
d.Name = patch.Name
}
d.LeadEmail = patch.LeadEmail
s.db.Save(&d)
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleDeleteDepartment(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.Department{}, "id = ?", chi.URLParam(r, "id"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- audit ----------------------------------------------------------------
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.AuditLog
s.db.Order("created_at desc").Limit(500).Find(&out)
writeJSON(w, http.StatusOK, out)
}