feat: spin 백엔드 전체 구현 (근무·프로젝트·인센티브·회계)
build-and-push / build (push) Successful in 39s

- 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>
This commit is contained in:
theorose49
2026-06-28 08:57:35 +09:00
co-authored by Claude Opus 4.8
parent ce4023ca59
commit f83724b995
28 changed files with 3960 additions and 5 deletions
+209
View File
@@ -0,0 +1,209 @@
package httpapi
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"strings"
)
// User is the authenticated principal carried on the request context. Identity
// comes from oauth2-proxy (Keycloak) in production; spin matches it to a Member
// row by email to resolve rank/role/admin within the app.
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Groups []string `json:"groups,omitempty"`
IsSuperAdmin bool `json:"isSuperAdmin"`
}
type ctxKey string
const userKey ctxKey = "user"
// devUser is the fixed identity injected when DEV_AUTH is enabled (local dev).
var devUser = User{ID: "u-admin", Name: "관리자", Email: "theorose49@gmail.com", Role: "관리자", Groups: []string{"admin"}}
// authMiddleware injects the authenticated user into the request context.
//
// - DEV_AUTH=true → a fixed mock identity (local development).
// - DEV_AUTH=false → the identity forwarded by oauth2-proxy after a successful
// Keycloak login (X-Forwarded-* / X-Auth-Request-* headers + token claims).
// The backend trusts these headers because it is only reachable behind the
// proxy; requests without them are rejected.
func authMiddleware(devAuth bool, adminGroups []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var u User
if devAuth {
u = devUser
// Local dev runs as a super-admin so every screen is reachable.
// Append ?as=user to any request to simulate a non-admin member.
u.IsSuperAdmin = r.URL.Query().Get("as") != "user"
if !u.IsSuperAdmin {
u = User{ID: "u-member", Name: "김주임", Email: "member@special-partners.com", Role: "주임", Groups: []string{"user"}}
}
} else {
var ok bool
u, ok = userFromProxyHeaders(r)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated (oauth2-proxy headers missing)")
return
}
u.IsSuperAdmin = groupsIntersect(u.Groups, adminGroups)
}
ctx := context.WithValue(r.Context(), userKey, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// groupsIntersect reports whether any of the user's groups is in adminGroups
// (case-insensitive).
func groupsIntersect(groups, adminGroups []string) bool {
for _, g := range groups {
for _, a := range adminGroups {
if strings.EqualFold(strings.TrimSpace(g), strings.TrimSpace(a)) {
return true
}
}
}
return false
}
// userFromProxyHeaders builds the principal from the headers oauth2-proxy
// injects. It prefers the X-Forwarded-* headers (--pass-user-headers), falls
// back to X-Auth-Request-* (--set-xauthrequest), and enriches the display name
// and role from the forwarded access-token claims when available.
func userFromProxyHeaders(r *http.Request) (User, bool) {
hdr := func(keys ...string) string {
for _, k := range keys {
if v := strings.TrimSpace(r.Header.Get(k)); v != "" {
return v
}
}
return ""
}
email := hdr("X-Forwarded-Email", "X-Auth-Request-Email")
username := hdr("X-Forwarded-Preferred-Username", "X-Auth-Request-Preferred-Username",
"X-Forwarded-User", "X-Auth-Request-User")
groups := splitGroups(hdr("X-Forwarded-Groups", "X-Auth-Request-Groups"))
claims := decodeTokenClaims(r)
if username == "" {
username = firstNonEmpty(claims.PreferredUsername, claims.Sub)
}
if email == "" {
email = claims.Email
}
if len(groups) == 0 {
groups = splitGroups(strings.Join(claims.Groups, ","))
}
// No identifying header at all → request did not come through oauth2-proxy.
if username == "" && email == "" {
return User{}, false
}
return User{
ID: firstNonEmpty(email, username, claims.Sub),
Name: firstNonEmpty(claims.Name, username, email),
Email: email,
Role: deriveRole(groups, claims.RealmAccess.Roles),
Groups: groups,
}, true
}
// oidcClaims is the subset of Keycloak token claims we read.
type oidcClaims struct {
Sub string `json:"sub"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Groups []string `json:"groups"`
RealmAccess struct {
Roles []string `json:"roles"`
} `json:"realm_access"`
}
// decodeTokenClaims reads the forwarded access token and decodes its JWT payload
// WITHOUT signature verification — oauth2-proxy already validated it upstream and
// the backend is only reachable behind the proxy. Returns zero claims if absent.
func decodeTokenClaims(r *http.Request) oidcClaims {
var c oidcClaims
tok := firstNonEmpty(
r.Header.Get("X-Forwarded-Access-Token"),
r.Header.Get("X-Auth-Request-Access-Token"),
)
if tok == "" {
if a := r.Header.Get("Authorization"); strings.HasPrefix(a, "Bearer ") {
tok = strings.TrimSpace(strings.TrimPrefix(a, "Bearer "))
}
}
if tok == "" {
return c
}
parts := strings.Split(tok, ".")
if len(parts) < 2 {
return c
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
if payload, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
return c
}
}
_ = json.Unmarshal(payload, &c)
return c
}
var ignoredRealmRoles = map[string]bool{"offline_access": true, "uma_authorization": true}
// deriveRole picks a human-readable role for display: the first Keycloak group,
// else the first non-default realm role, else "구성원".
func deriveRole(groups, realmRoles []string) string {
for _, g := range groups {
if g != "" {
return g
}
}
for _, role := range realmRoles {
if role == "" || ignoredRealmRoles[role] || strings.HasPrefix(role, "default-roles") {
continue
}
return role
}
return "구성원"
}
func splitGroups(raw string) []string {
var out []string
for _, g := range strings.Split(raw, ",") {
g = strings.TrimPrefix(strings.TrimSpace(g), "/")
if g != "" {
out = append(out, g)
}
}
return out
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// currentUser returns the authenticated user from context.
func currentUser(ctx context.Context) User {
if u, ok := ctx.Value(userKey).(User); ok {
return u
}
return devUser
}
+20
View File
@@ -0,0 +1,20 @@
package httpapi
import (
"testing"
"spin/internal/models"
"gorm.io/datatypes"
)
func TestToEngineConfigQuota(t *testing.T) {
c := models.IncentiveConfig{
PointRate: 1_000_000,
RankQuota: datatypes.JSONMap{"주임": 30.0, "선임": 50.0},
}
eng := toEngineConfig(c)
if eng.RankQuota["주임"] != 30 {
t.Fatalf("expected 30, got %v (map=%v)", eng.RankQuota["주임"], eng.RankQuota)
}
}
+37
View File
@@ -0,0 +1,37 @@
package httpapi
import (
"fmt"
"os"
"testing"
"spin/internal/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// Run with: SPIN_TEST_DB=1 go test ./internal/httpapi -run TestDBLoadQuota -v
func TestDBLoadQuota(t *testing.T) {
if os.Getenv("SPIN_TEST_DB") == "" {
t.Skip("set SPIN_TEST_DB=1 to run against local compose db")
}
dsn := "postgres://spin:spin@localhost:5580/spin?sslmode=disable"
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
var c models.IncentiveConfig
if err := gdb.Where("year = ?", 2026).First(&c).Error; err != nil {
t.Fatal(err)
}
fmt.Printf("loaded RankQuota=%#v\n", c.RankQuota)
for k, v := range c.RankQuota {
fmt.Printf(" key=%q val=%#v type=%T\n", k, v, v)
}
eng := toEngineConfig(c)
fmt.Printf("eng.RankQuota=%#v\n", eng.RankQuota)
if eng.RankQuota["주임"] != 30 {
t.Fatalf("expected 30, got %v", eng.RankQuota["주임"])
}
}
+61
View File
@@ -0,0 +1,61 @@
package httpapi
import (
"net/http"
"strings"
"spin/internal/models"
)
// MeResponse enriches the proxy identity with the matched Member profile.
type MeResponse struct {
User User `json:"user"`
Member *models.Member `json:"member"`
IsAdmin bool `json:"isAdmin"`
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
u := currentUser(r.Context())
writeJSON(w, http.StatusOK, MeResponse{
User: u,
Member: s.lookupMember(u.Email),
IsAdmin: s.isAdmin(r),
})
}
// NavItem is one sidebar entry; adminOnly entries are filtered for members.
type NavItem struct {
Key string `json:"key"`
Label string `json:"label"`
Path string `json:"path"`
Icon string `json:"icon"`
AdminOnly bool `json:"adminOnly"`
Section string `json:"section"`
}
var navItems = []NavItem{
{Key: "dashboard", Label: "대시보드", Path: "/", Icon: "LayoutDashboard", Section: "개요"},
{Key: "attendance", Label: "근무", Path: "/attendance", Icon: "Clock", Section: "나의 업무"},
{Key: "projects", Label: "프로젝트", Path: "/projects", Icon: "FolderKanban", Section: "나의 업무"},
{Key: "incentive", Label: "인센티브", Path: "/incentive", Icon: "Coins", Section: "나의 업무"},
{Key: "approvals", Label: "승인 관리", Path: "/admin/approvals", Icon: "CheckSquare", AdminOnly: true, Section: "관리자"},
{Key: "incentive-admin", Label: "인센티브 관리", Path: "/admin/incentive", Icon: "Calculator", AdminOnly: true, Section: "관리자"},
{Key: "accounting", Label: "회계", Path: "/admin/accounting", Icon: "Wallet", AdminOnly: true, Section: "관리자"},
{Key: "members", Label: "구성원", Path: "/admin/members", Icon: "Users", AdminOnly: true, Section: "관리자"},
{Key: "settings", Label: "설정", Path: "/admin/settings", Icon: "Settings", AdminOnly: true, Section: "관리자"},
}
func (s *Server) handleNav(w http.ResponseWriter, r *http.Request) {
admin := s.isAdmin(r)
out := make([]NavItem, 0, len(navItems))
for _, it := range navItems {
if it.AdminOnly && !admin {
continue
}
out = append(out, it)
}
writeJSON(w, http.StatusOK, out)
}
// lc lower-cases & trims a string (small helper used across handlers).
func lc(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
+225
View File
@@ -0,0 +1,225 @@
package httpapi
import (
"net/http"
"strconv"
"time"
"spin/internal/models"
"github.com/go-chi/chi/v5"
)
// All accounting is admin-only (전사 재무 데이터).
func (s *Server) handleListAccounts(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.Account
s.db.Order("code asc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var a models.Account
if err := decodeJSON(r, &a); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&a)
writeJSON(w, http.StatusCreated, a)
}
func (s *Server) handleListTransactions(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
q := s.db.Order("date desc, created_at desc")
if k := r.URL.Query().Get("kind"); k != "" {
q = q.Where("kind = ?", k)
}
if pid := r.URL.Query().Get("projectId"); pid != "" {
q = q.Where("project_id = ?", pid)
}
if from := r.URL.Query().Get("from"); from != "" {
q = q.Where("date >= ?", from)
}
if to := r.URL.Query().Get("to"); to != "" {
q = q.Where("date <= ?", to)
}
var out []models.Transaction
q.Limit(1000).Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateTransaction(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var t models.Transaction
if err := decodeJSON(r, &t); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
t.CreatedBy = currentUser(r.Context()).Email
s.db.Create(&t)
s.audit(r, "create", "transaction", t.ID, t.Kind)
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handlePatchTransaction(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var t models.Transaction
if err := s.db.First(&t, "id = ?", chi.URLParam(r, "txId")).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")
s.db.Model(&t).Updates(patch)
s.db.First(&t, "id = ?", t.ID)
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleDeleteTransaction(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
s.db.Delete(&models.Transaction{}, "id = ?", chi.URLParam(r, "txId"))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleListTaxes(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.TaxRecord
s.db.Order("due_date desc").Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateTax(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var t models.TaxRecord
if err := decodeJSON(r, &t); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.db.Create(&t)
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handlePatchTax(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var t models.TaxRecord
if err := s.db.First(&t, "id = ?", chi.URLParam(r, "taxId")).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")
s.db.Model(&t).Updates(patch)
s.db.First(&t, "id = ?", t.ID)
writeJSON(w, http.StatusOK, t)
}
// ---- summary: cashflow vs incentive gap -----------------------------------
type acctSummary struct {
Year int `json:"year"`
CashIn float64 `json:"cashIn"`
CashOut float64 `json:"cashOut"`
Net float64 `json:"net"`
IncentiveApplied float64 `json:"incentiveApplied"` // KRW value of applied points
IncentivePaid float64 `json:"incentivePaid"` // actually disbursed (지급완료)
Gap float64 `json:"gap"` // applied − paid (미지급 부채성)
Monthly []monthlyPL `json:"monthly"`
ByKind map[string]float64 `json:"byKind"`
}
type monthlyPL struct {
Month string `json:"month"`
Income float64 `json:"income"`
Expense float64 `json:"expense"`
Net float64 `json:"net"`
}
func (s *Server) handleAccountingSummary(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
year := yearParam(r)
prefix := strconv.Itoa(year)
var txns []models.Transaction
s.db.Where("date LIKE ?", prefix+"%").Find(&txns)
sum := acctSummary{Year: year, ByKind: map[string]float64{}}
monthly := map[string]*monthlyPL{}
for m := 1; m <= 12; m++ {
key := prefix + "-" + pad2(m)
monthly[key] = &monthlyPL{Month: key}
}
for _, t := range txns {
sum.ByKind[t.Kind] += t.Amount
mk := t.Date
if len(mk) >= 7 {
mk = mk[:7]
}
row := monthly[mk]
if row == nil {
row = &monthlyPL{Month: mk}
monthly[mk] = row
}
if t.Kind == models.TxnIncome {
sum.CashIn += t.Amount
row.Income += t.Amount
} else {
out := t.Amount
if out < 0 {
out = -out
}
sum.CashOut += out
row.Expense += out
}
row.Net = row.Income - row.Expense
}
sum.Net = sum.CashIn - sum.CashOut
// incentive applied (반영완료/지급완료) point value vs actually paid (지급완료)
var appliedPts, paidPts float64
s.db.Model(&models.UserIncentive{}).Where("year = ? AND (fix_status = ? OR fix_status = ?)",
year, models.FixApplied, models.FixPaid).Select("COALESCE(SUM(points),0)").Scan(&appliedPts)
s.db.Model(&models.UserIncentive{}).Where("year = ? AND fix_status = ?",
year, models.FixPaid).Select("COALESCE(SUM(points),0)").Scan(&paidPts)
_, eng := s.incentiveConfig(year)
sum.IncentiveApplied = appliedPts * eng.PointRate
sum.IncentivePaid = paidPts * eng.PointRate
sum.Gap = sum.IncentiveApplied - sum.IncentivePaid
// ordered monthly slice
for m := 1; m <= 12; m++ {
key := prefix + "-" + pad2(m)
sum.Monthly = append(sum.Monthly, *monthly[key])
}
writeJSON(w, http.StatusOK, sum)
}
var _ = time.Now
+345
View File
@@ -0,0 +1,345 @@
package httpapi
import (
"net/http"
"strconv"
"time"
"spin/internal/models"
"spin/internal/worktime"
"github.com/go-chi/chi/v5"
)
// scopeEmail resolves which member's data a request targets. Members are always
// scoped to themselves; admins may pass ?email= to inspect anyone (or "" = all).
func (s *Server) scopeEmail(r *http.Request) (email string, all bool) {
if !s.isAdmin(r) {
return s.email(r), false
}
q := lc(r.URL.Query().Get("email"))
if q == "" {
return "", true
}
return q, false
}
// ---- attendance -----------------------------------------------------------
func (s *Server) handleListAttendance(w http.ResponseWriter, r *http.Request) {
email, all := s.scopeEmail(r)
q := s.db.Order("date desc")
if !all {
q = q.Where("lower(member_email) = ?", email)
}
if month := r.URL.Query().Get("month"); month != "" { // YYYY-MM
q = q.Where("date LIKE ?", month+"%")
}
var out []models.Attendance
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handlePunch records a clock-in (first call of the day) or clock-out.
func (s *Server) handlePunch(w http.ResponseWriter, r *http.Request) {
email := s.email(r)
now := time.Now()
date := now.Format("2006-01-02")
pol := s.activeWorkPolicy()
var a models.Attendance
err := s.db.Where("lower(member_email) = ? AND date = ?", email, date).First(&a).Error
if err != nil {
a = models.Attendance{MemberEmail: email, Date: date, ClockIn: &now, Source: "web"}
s.db.Create(&a)
writeJSON(w, http.StatusOK, a)
return
}
// already has a record → set clock-out and compute worked minutes
a.ClockOut = &now
a.WorkMinutes = worktime.NetWorkMinutes(a.ClockIn, a.ClockOut, pol.LunchMinutes)
s.db.Save(&a)
writeJSON(w, http.StatusOK, a)
}
// handleTimesheet returns the monthly roll-up for the scoped member.
func (s *Server) handleTimesheet(w http.ResponseWriter, r *http.Request) {
email := s.email(r)
if s.isAdmin(r) {
if q := lc(r.URL.Query().Get("email")); q != "" {
email = q
}
}
now := time.Now()
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
month, _ := strconv.Atoi(r.URL.Query().Get("month"))
if year == 0 {
year = now.Year()
}
if month == 0 {
month = int(now.Month())
}
prefix := strconv.Itoa(year) + "-" + pad2(month)
pol := s.activeWorkPolicy()
var att []models.Attendance
s.db.Where("lower(member_email) = ? AND date LIKE ?", email, prefix+"%").Find(&att)
worked, days := 0, 0
for _, a := range att {
worked += a.WorkMinutes
if a.WorkMinutes > 0 {
days++
}
}
// recognized leave minutes (approved annual/public/etc within month)
var leaves []models.LeaveRequest
s.db.Where("lower(member_email) = ? AND status = ? AND start_date LIKE ?",
email, models.StatusApproved, prefix+"%").Find(&leaves)
leaveMin := 0
for _, lv := range leaves {
if lv.Type == models.LeaveUnpaid {
continue
}
leaveMin += int(lv.Days * float64(pol.DailyStandardMin))
}
var ots []models.OvertimeRequest
s.db.Where("lower(member_email) = ? AND status = ? AND date LIKE ?",
email, models.StatusApproved, prefix+"%").Find(&ots)
otMin := 0
for _, o := range ots {
otMin += o.Minutes
}
writeJSON(w, http.StatusOK, worktime.Compute(year, month, pol.DailyStandardMin, worked, leaveMin, otMin, days))
}
func pad2(n int) string {
if n < 10 {
return "0" + strconv.Itoa(n)
}
return strconv.Itoa(n)
}
// ---- leave ----------------------------------------------------------------
func (s *Server) handleListLeave(w http.ResponseWriter, r *http.Request) {
email, all := s.scopeEmail(r)
q := s.db.Order("created_at desc")
if !all {
q = q.Where("lower(member_email) = ?", email)
}
if st := r.URL.Query().Get("status"); st != "" {
q = q.Where("status = ?", st)
}
var out []models.LeaveRequest
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateLeave(w http.ResponseWriter, r *http.Request) {
var lv models.LeaveRequest
if err := decodeJSON(r, &lv); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
lv.MemberEmail = s.email(r) // members can only file for themselves
lv.Status = models.StatusPending
lv.Approver = ""
if lv.Days == 0 {
lv.Days = leaveDays(lv)
}
if err := s.db.Create(&lv).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, lv)
}
// leaveDays estimates day count from the date span (half-day types = 0.5).
func leaveDays(lv models.LeaveRequest) float64 {
if lv.Type == models.LeaveHalfAM || lv.Type == models.LeaveHalfPM {
return 0.5
}
s, e1 := lv.StartDate, lv.EndDate
if e1 == "" {
e1 = s
}
t1, err1 := time.Parse("2006-01-02", s)
t2, err2 := time.Parse("2006-01-02", e1)
if err1 != nil || err2 != nil {
return 1
}
return t2.Sub(t1).Hours()/24 + 1
}
type decision struct {
Approve bool `json:"approve"`
Memo string `json:"memo"`
}
func (s *Server) handleDecideLeave(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var lv models.LeaveRequest
if err := s.db.First(&lv, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "신청을 찾을 수 없습니다")
return
}
var d decision
decodeJSON(r, &d)
now := time.Now()
lv.Status = models.StatusRejected
if d.Approve {
lv.Status = models.StatusApproved
s.applyLeaveBalance(lv)
}
lv.Approver = currentUser(r.Context()).Email
lv.DecidedAt = &now
lv.DecisionMemo = d.Memo
s.db.Save(&lv)
s.audit(r, "decide", "leave", lv.ID, lv.Status)
writeJSON(w, http.StatusOK, lv)
}
// applyLeaveBalance draws down the annual leave balance for 연차 types.
func (s *Server) applyLeaveBalance(lv models.LeaveRequest) {
if lv.Type != models.LeaveAnnual && lv.Type != models.LeaveHalfAM && lv.Type != models.LeaveHalfPM {
return
}
year := time.Now().Year()
if t, err := time.Parse("2006-01-02", lv.StartDate); err == nil {
year = t.Year()
}
var bal models.LeaveBalance
if err := s.db.Where("lower(member_email) = ? AND year = ?", lc(lv.MemberEmail), year).First(&bal).Error; err != nil {
bal = models.LeaveBalance{MemberEmail: lv.MemberEmail, Year: year, Granted: 15}
s.db.Create(&bal)
}
bal.Used += lv.Days
s.db.Save(&bal)
}
func (s *Server) handleCancelLeave(w http.ResponseWriter, r *http.Request) {
var lv models.LeaveRequest
if err := s.db.First(&lv, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "신청을 찾을 수 없습니다")
return
}
if !s.isAdmin(r) && !s.owns(r, lv.MemberEmail) {
writeError(w, http.StatusForbidden, "본인 신청만 취소할 수 있습니다")
return
}
lv.Status = models.StatusCanceled
s.db.Save(&lv)
writeJSON(w, http.StatusOK, lv)
}
// ---- overtime -------------------------------------------------------------
func (s *Server) handleListOvertime(w http.ResponseWriter, r *http.Request) {
email, all := s.scopeEmail(r)
q := s.db.Order("created_at desc")
if !all {
q = q.Where("lower(member_email) = ?", email)
}
var out []models.OvertimeRequest
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateOvertime(w http.ResponseWriter, r *http.Request) {
var o models.OvertimeRequest
if err := decodeJSON(r, &o); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
o.MemberEmail = s.email(r)
o.Status = models.StatusPending
if err := s.db.Create(&o).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, o)
}
func (s *Server) handleDecideOvertime(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var o models.OvertimeRequest
if err := s.db.First(&o, "id = ?", chi.URLParam(r, "id")).Error; err != nil {
writeError(w, http.StatusNotFound, "신청을 찾을 수 없습니다")
return
}
var d decision
decodeJSON(r, &d)
now := time.Now()
o.Status = models.StatusRejected
if d.Approve {
o.Status = models.StatusApproved
}
o.Approver = currentUser(r.Context()).Email
o.DecidedAt = &now
o.DecisionMemo = d.Memo
s.db.Save(&o)
s.audit(r, "decide", "overtime", o.ID, o.Status)
writeJSON(w, http.StatusOK, o)
}
// ---- work policy ----------------------------------------------------------
// activeWorkPolicy returns the active policy or a sane default.
func (s *Server) activeWorkPolicy() models.WorkPolicy {
var p models.WorkPolicy
if err := s.db.Where("active = ?", true).First(&p).Error; err != nil {
return models.WorkPolicy{WeeklyHours: 40, DailyStandardMin: 480, LunchMinutes: 60,
CoreStart: "09:00", CoreEnd: "18:00", AnnualLeaveBase: 15, Active: true}
}
return p
}
func (s *Server) handleGetWorkPolicy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.activeWorkPolicy())
}
func (s *Server) handlePutWorkPolicy(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var in models.WorkPolicy
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var p models.WorkPolicy
if err := s.db.Where("active = ?", true).First(&p).Error; err != nil {
in.Active = true
s.db.Create(&in)
writeJSON(w, http.StatusOK, in)
return
}
in.ID = p.ID
in.Active = true
s.db.Save(&in)
writeJSON(w, http.StatusOK, in)
}
// ---- approval queue (admin) ----------------------------------------------
type approvalQueue struct {
Leave []models.LeaveRequest `json:"leave"`
Overtime []models.OvertimeRequest `json:"overtime"`
}
func (s *Server) handleApprovalQueue(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var q approvalQueue
s.db.Where("status = ?", models.StatusPending).Order("created_at asc").Find(&q.Leave)
s.db.Where("status = ?", models.StatusPending).Order("created_at asc").Find(&q.Overtime)
writeJSON(w, http.StatusOK, q)
}
+59
View File
@@ -0,0 +1,59 @@
package httpapi
import (
"net/http"
"strconv"
"time"
"spin/internal/models"
)
// handleDashboard returns a role-tailored summary. Members get their own work /
// incentive / project snapshot; admins additionally get company-wide widgets.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
email := s.email(r)
year := time.Now().Year()
out := map[string]interface{}{"isAdmin": s.isAdmin(r), "year": year}
// my projects count
out["myProjects"] = len(s.myProjectIDs(email))
// my applied incentive points this year
var myPoints float64
s.db.Model(&models.UserIncentive{}).
Where("lower(member_email) = ? AND year = ? AND (fix_status = ? OR fix_status = ?)",
email, year, models.FixApplied, models.FixPaid).
Select("COALESCE(SUM(points),0)").Scan(&myPoints)
out["myPoints"] = myPoints
// my pending requests
var myPending int64
s.db.Model(&models.LeaveRequest{}).Where("lower(member_email) = ? AND status = ?", email, models.StatusPending).Count(&myPending)
out["myPendingRequests"] = myPending
if s.isAdmin(r) {
var pendingLeave, pendingOT, activeProjects int64
s.db.Model(&models.LeaveRequest{}).Where("status = ?", models.StatusPending).Count(&pendingLeave)
s.db.Model(&models.OvertimeRequest{}).Where("status = ?", models.StatusPending).Count(&pendingOT)
s.db.Model(&models.Project{}).Where("status = ?", "active").Count(&activeProjects)
out["pendingApprovals"] = pendingLeave + pendingOT
out["activeProjects"] = activeProjects
// cash position this year
var cashIn, cashOut float64
yr := strconv.Itoa(year)
s.db.Model(&models.Transaction{}).Where("date LIKE ? AND kind = ?", yr+"%", models.TxnIncome).
Select("COALESCE(SUM(amount),0)").Scan(&cashIn)
s.db.Model(&models.Transaction{}).Where("date LIKE ? AND kind <> ?", yr+"%", models.TxnIncome).
Select("COALESCE(SUM(ABS(amount)),0)").Scan(&cashOut)
out["cashIn"] = cashIn
out["cashOut"] = cashOut
out["cashNet"] = cashIn - cashOut
// upcoming payment splits (expected, unpaid)
var upcoming []models.PaymentSplit
s.db.Where("paid = ? AND expected_date <> ''", false).Order("expected_date asc").Limit(8).Find(&upcoming)
out["upcomingPayments"] = upcoming
}
writeJSON(w, http.StatusOK, out)
}
+450
View File
@@ -0,0 +1,450 @@
package httpapi
import (
"encoding/json"
"net/http"
"strconv"
"time"
"spin/internal/incentive"
"spin/internal/models"
"github.com/go-chi/chi/v5"
)
// ---- config ---------------------------------------------------------------
// incentiveConfig loads (or defaults) the IncentiveConfig for a year and adapts
// it to the engine's plain Config.
func (s *Server) incentiveConfig(year int) (models.IncentiveConfig, incentive.Config) {
var c models.IncentiveConfig
if err := s.db.Where("year = ?", year).First(&c).Error; err != nil {
c = models.IncentiveConfig{Year: year, PointRate: 1_000_000, DepositPct: 30,
MiddlePct: 40, FinalPct: 30, NonBECompanyPct: 60, NonBEPartnerPct: 40,
RankQuota: defaultQuota()}
}
return c, toEngineConfig(c)
}
func defaultQuota() map[string]interface{} {
return map[string]interface{}{
models.RankJunior: 30.0, models.RankSenior: 50.0,
models.RankLead: 80.0, models.RankPartner: 120.0,
}
}
func toEngineConfig(c models.IncentiveConfig) incentive.Config {
quota := map[string]float64{}
for k, v := range c.RankQuota {
if f, ok := toFloat(v); ok {
quota[k] = f
}
}
return incentive.Config{
PointRate: c.PointRate, DepositPct: c.DepositPct, MiddlePct: c.MiddlePct,
FinalPct: c.FinalPct, NonBECompanyPct: c.NonBECompanyPct,
NonBEPartnerPct: c.NonBEPartnerPct, RankQuota: quota,
}
}
func toFloat(v interface{}) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
case json.Number:
// GORM's datatypes.JSONMap decodes JSON numbers as json.Number.
f, err := n.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(n, 64)
return f, err == nil
}
return 0, false
}
func yearParam(r *http.Request) int {
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y > 0 {
return y
}
return time.Now().Year()
}
func (s *Server) handleGetIncentiveConfig(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
c, _ := s.incentiveConfig(yearParam(r))
writeJSON(w, http.StatusOK, c)
}
func (s *Server) handlePutIncentiveConfig(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var in models.IncentiveConfig
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Year == 0 {
in.Year = time.Now().Year()
}
var existing models.IncentiveConfig
if err := s.db.Where("year = ?", in.Year).First(&existing).Error; err == nil {
in.ID = existing.ID
s.db.Save(&in)
} else {
s.db.Create(&in)
}
s.audit(r, "update", "incentive_config", strconv.Itoa(in.Year), "")
writeJSON(w, http.StatusOK, in)
}
// ---- recompute (rebuild stages + allocations from contract) ---------------
func (s *Server) projectMemberPortions(projectID string) []incentive.MemberPortion {
var pms []models.ProjectMember
s.db.Where("project_id = ?", projectID).Find(&pms)
out := make([]incentive.MemberPortion, 0, len(pms))
for _, pm := range pms {
isPartner := false
if m := s.lookupMember(pm.MemberEmail); m != nil {
isPartner = m.IsPartner || m.Rank == models.RankPartner
}
out = append(out, incentive.MemberPortion{Email: pm.MemberEmail, Portion: pm.Portion, IsPartner: isPartner})
}
return out
}
func (s *Server) handleRecomputeProject(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
pid := chi.URLParam(r, "id")
var contract models.Contract
if err := s.db.First(&contract, "project_id = ?", pid).Error; err != nil {
writeError(w, http.StatusBadRequest, "계약 정보(BE/계약금액)를 먼저 입력하세요")
return
}
year := yearParam(r)
_, eng := s.incentiveConfig(year)
stages := incentive.ComputeStages(contract.TotalAmount, contract.BEAmount, eng)
// upsert PaymentStage rows (keep status/dates on existing kind×scope).
for _, st := range stages {
var existing models.PaymentStage
err := s.db.Where("project_id = ? AND kind = ? AND scope = ?", pid, st.Kind, st.Scope).First(&existing).Error
if err != nil {
s.db.Create(&models.PaymentStage{ProjectID: pid, Kind: st.Kind, Scope: st.Scope,
Amount: st.Amount, Pct: st.Pct, Status: models.FixPlanned})
} else {
existing.Amount = st.Amount
existing.Pct = st.Pct
s.db.Save(&existing)
}
}
// rebuild non-override UserIncentive rows.
members := s.projectMemberPortions(pid)
allocs := incentive.ComputeAllocs(stages, members, eng)
s.db.Where("project_id = ? AND \"override\" = ?", pid, false).Delete(&models.UserIncentive{})
var stageRows []models.PaymentStage
s.db.Where("project_id = ?", pid).Find(&stageRows)
stageID := map[string]string{}
stageStatus := map[string]string{}
for _, sr := range stageRows {
stageID[sr.Kind+"|"+sr.Scope] = sr.ID
stageStatus[sr.Kind+"|"+sr.Scope] = sr.Status
}
for _, a := range allocs {
key := a.Kind + "|" + a.Scope
// skip member+stage that already has an override row
var cnt int64
s.db.Model(&models.UserIncentive{}).
Where("project_id = ? AND lower(member_email) = lower(?) AND kind = ? AND scope = ? AND \"override\" = ?",
pid, a.Email, a.Kind, a.Scope, true).Count(&cnt)
if cnt > 0 {
continue
}
s.db.Create(&models.UserIncentive{ProjectID: pid, MemberEmail: a.Email, StageID: stageID[key],
Kind: a.Kind, Scope: a.Scope, Year: year, Portion: a.Portion, Amount: a.Amount,
Points: a.Points, FixStatus: stageStatus[key]})
}
s.audit(r, "recompute", "incentive", pid, "")
writeJSON(w, http.StatusOK, map[string]interface{}{"stages": len(stages), "allocs": len(allocs)})
}
func (s *Server) handleListStages(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var out []models.PaymentStage
q := s.db.Order("scope asc, kind asc")
if pid := r.URL.Query().Get("projectId"); pid != "" {
q = q.Where("project_id = ?", pid)
}
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handleSetStageStatus toggles a stage's fix lifecycle and propagates the status
// to its non-override UserIncentive rows (반영중 → 반영완료 등).
func (s *Server) handleSetStageStatus(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var st models.PaymentStage
if err := s.db.First(&st, "id = ?", chi.URLParam(r, "stId")).Error; err != nil {
writeError(w, http.StatusNotFound, "단계를 찾을 수 없습니다")
return
}
var body struct {
Status string `json:"status"`
FixedDate string `json:"fixedDate"`
}
decodeJSON(r, &body)
st.Status = body.Status
if body.FixedDate != "" {
st.FixedDate = body.FixedDate
}
s.db.Save(&st)
// propagate to non-override allocations
now := time.Now()
updates := map[string]interface{}{"fix_status": body.Status}
if body.Status == models.FixApplied {
updates["applied_at"] = &now
}
if body.Status == models.FixPaid {
updates["paid_at"] = &now
}
s.db.Model(&models.UserIncentive{}).
Where("stage_id = ? AND \"override\" = ?", st.ID, false).Updates(updates)
s.audit(r, "stage_status", "payment_stage", st.ID, body.Status)
writeJSON(w, http.StatusOK, st)
}
// ---- user incentives ------------------------------------------------------
func (s *Server) handleListUserIncentives(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
q := s.db.Order("created_at desc")
if pid := r.URL.Query().Get("projectId"); pid != "" {
q = q.Where("project_id = ?", pid)
}
if em := lc(r.URL.Query().Get("email")); em != "" {
q = q.Where("lower(member_email) = ?", em)
}
var out []models.UserIncentive
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handlePatchUserIncentive is the custom-override entry point: any field can be
// hand-edited and the row is flagged Override so recompute won't clobber it.
func (s *Server) handlePatchUserIncentive(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var ui models.UserIncentive
if err := s.db.First(&ui, "id = ?", chi.URLParam(r, "uiId")).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")
patch["override"] = true // any manual edit pins the row
s.db.Model(&ui).Updates(patch)
s.db.First(&ui, "id = ?", ui.ID)
s.audit(r, "override", "user_incentive", ui.ID, "")
writeJSON(w, http.StatusOK, ui)
}
// ---- member dashboard -----------------------------------------------------
type myIncentive struct {
Year int `json:"year"`
Rank string `json:"rank"`
Quota float64 `json:"quota"`
PointsTotal float64 `json:"pointsTotal"` // all points (any status)
PointsApplied float64 `json:"pointsApplied"` // applied+paid
ExcessPoints float64 `json:"excessPoints"`
PointRate float64 `json:"pointRate"`
EstPayout float64 `json:"estPayout"`
Items []models.UserIncentive `json:"items"`
ByProject map[string]float64 `json:"byProject"`
}
func (s *Server) handleMyIncentive(w http.ResponseWriter, r *http.Request) {
email := s.email(r)
if s.isAdmin(r) {
if q := lc(r.URL.Query().Get("email")); q != "" {
email = q
}
}
year := yearParam(r)
cfg, eng := s.incentiveConfig(year)
var items []models.UserIncentive
s.db.Where("lower(member_email) = ? AND year = ?", email, year).Order("created_at desc").Find(&items)
total, applied := 0.0, 0.0
byProject := map[string]float64{}
for _, it := range items {
total += it.Points
if it.FixStatus == models.FixApplied || it.FixStatus == models.FixPaid {
applied += it.Points
}
byProject[it.ProjectID] += it.Points
}
rank := ""
if m := s.lookupMember(email); m != nil {
rank = m.Rank
}
st := incentive.ComputeSettlement(email, rank, applied, 0, eng)
writeJSON(w, http.StatusOK, myIncentive{
Year: year, Rank: rank, Quota: st.Quota, PointsTotal: total, PointsApplied: applied,
ExcessPoints: st.ExcessPoints, PointRate: cfg.PointRate, EstPayout: st.PayoutAmount,
Items: items, ByProject: byProject,
})
}
// ---- quarterly settlement -------------------------------------------------
func (s *Server) handleListSettlements(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
q := s.db.Order("year desc, quarter desc")
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y > 0 {
q = q.Where("year = ?", y)
}
var out []models.QuarterlySettlement
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handleRunSettlement computes (or refreshes) settlement rows for a year+quarter.
func (s *Server) handleRunSettlement(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var body struct {
Year int `json:"year"`
Quarter int `json:"quarter"`
}
decodeJSON(r, &body)
if body.Year == 0 {
body.Year = time.Now().Year()
}
if body.Quarter == 0 {
body.Quarter = (int(time.Now().Month())-1)/3 + 1
}
_, eng := s.incentiveConfig(body.Year)
// applied points per member up to this quarter (cumulative within the year).
var members []models.Member
s.db.Find(&members)
var results []models.QuarterlySettlement
for _, m := range members {
var applied float64
s.db.Model(&models.UserIncentive{}).
Where("lower(member_email) = lower(?) AND year = ? AND (fix_status = ? OR fix_status = ?)",
m.Email, body.Year, models.FixApplied, models.FixPaid).
Select("COALESCE(SUM(points),0)").Scan(&applied)
// prior paid this year
var paidYTD float64
s.db.Model(&models.QuarterlySettlement{}).
Where("lower(member_email) = lower(?) AND year = ? AND quarter < ? AND fixed = ?",
m.Email, body.Year, body.Quarter, true).
Select("COALESCE(SUM(payout_points),0)").Scan(&paidYTD)
st := incentive.ComputeSettlement(m.Email, m.Rank, applied, paidYTD, eng)
if st.PointsCumul == 0 && st.PayoutPoints == 0 {
continue
}
var existing models.QuarterlySettlement
row := models.QuarterlySettlement{MemberEmail: m.Email, Year: body.Year, Quarter: body.Quarter,
Rank: m.Rank, Quota: st.Quota, PointsCumul: st.PointsCumul, ExcessPoints: st.ExcessPoints,
PaidPointsYTD: st.PaidPointsYTD, PayoutPoints: st.PayoutPoints, PayoutAmount: st.PayoutAmount}
if err := s.db.Where("lower(member_email)=lower(?) AND year=? AND quarter=?", m.Email, body.Year, body.Quarter).First(&existing).Error; err == nil {
if !existing.Fixed { // never overwrite a fixed row
row.ID = existing.ID
s.db.Save(&row)
}
row = existing
} else {
s.db.Create(&row)
}
results = append(results, row)
}
s.audit(r, "settlement_run", "settlement", strconv.Itoa(body.Year)+"Q"+strconv.Itoa(body.Quarter), "")
writeJSON(w, http.StatusOK, results)
}
// handleFixSettlement locks a settlement row (그 차익이 급여로 확정 지급).
func (s *Server) handleFixSettlement(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var st models.QuarterlySettlement
if err := s.db.First(&st, "id = ?", chi.URLParam(r, "sId")).Error; err != nil {
writeError(w, http.StatusNotFound, "정산을 찾을 수 없습니다")
return
}
now := time.Now()
st.Fixed = true
st.FixedAt = &now
s.db.Save(&st)
s.audit(r, "settlement_fix", "settlement", st.ID, "")
writeJSON(w, http.StatusOK, st)
}
// ---- simulation (pure, no persistence) ------------------------------------
type simRequest struct {
Year int `json:"year"`
Total float64 `json:"total"`
BE float64 `json:"be"`
Members []incentive.MemberPortion `json:"members"`
Config *incentive.Config `json:"config"` // optional overrides
}
func (s *Server) handleSimulate(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var req simRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, eng := s.incentiveConfig(yearParam(r))
if req.Config != nil {
eng = *req.Config
if eng.PointRate == 0 {
eng.PointRate = 1
}
}
stages := incentive.ComputeStages(req.Total, req.BE, eng)
allocs := incentive.ComputeAllocs(stages, req.Members, eng)
// per-member point totals
byMember := map[string]float64{}
for _, a := range allocs {
byMember[a.Email] += a.Points
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"stages": stages, "allocs": allocs, "byMember": byMember,
})
}
+207
View File
@@ -0,0 +1,207 @@
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)
}
+507
View File
@@ -0,0 +1,507 @@
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")
if !s.isAdmin(r) {
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)
}
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
+78
View File
@@ -0,0 +1,78 @@
package httpapi
import (
"net/http"
"strings"
"spin/internal/models"
)
// spin is a single internal company, so authorization is a 2-tier model rather
// than eQMS's per-company membership matrix:
//
// - admin → super-admin (Keycloak group ∩ ADMIN_GROUPS) OR Member.Role==admin.
// Sees and manages everything: approvals, incentive console,
// accounting, all member/project data, the bracketed [admin-only]
// contract & payment fields.
// - member → sees only their OWN data and may only SUBMIT requests.
//
// Ownership is enforced per-handler by comparing the row's member email to the
// caller's email (case-insensitive).
// isSuperAdmin reports the Keycloak/dev super-admin flag from the auth middleware.
func (s *Server) isSuperAdmin(r *http.Request) bool {
return currentUser(r.Context()).IsSuperAdmin
}
// isAdmin reports whether the caller may manage company-wide data: either a
// super-admin or a Member whose Role is admin.
func (s *Server) isAdmin(r *http.Request) bool {
if s.isSuperAdmin(r) {
return true
}
m := s.lookupMember(currentUser(r.Context()).Email)
return m != nil && m.Role == models.RoleAdmin
}
// requireAdmin writes 403 and returns false when the caller is not an admin.
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if s.isAdmin(r) {
return true
}
writeError(w, http.StatusForbidden, "관리자 권한이 필요합니다")
return false
}
// email returns the caller's (lowercased) email.
func (s *Server) email(r *http.Request) string {
return strings.ToLower(strings.TrimSpace(currentUser(r.Context()).Email))
}
// owns reports whether the given member email belongs to the caller.
func (s *Server) owns(r *http.Request, memberEmail string) bool {
return strings.EqualFold(strings.TrimSpace(memberEmail), s.email(r))
}
// lookupMember loads the Member row matched to an email (nil if none).
func (s *Server) lookupMember(email string) *models.Member {
email = strings.TrimSpace(email)
if email == "" {
return nil
}
var m models.Member
if err := s.db.Where("lower(email) = lower(?)", email).First(&m).Error; err != nil {
return nil
}
return &m
}
// audit writes an AuditLog row (best-effort).
func (s *Server) audit(r *http.Request, action, entity, entityID, detail string) {
s.db.Create(&models.AuditLog{
Actor: currentUser(r.Context()).Email,
Action: action,
Entity: entity,
EntityID: entityID,
Detail: detail,
})
}
+165
View File
@@ -0,0 +1,165 @@
package httpapi
import (
"encoding/json"
"net/http"
"spin/internal/config"
"spin/internal/storage"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"gorm.io/gorm"
)
// Server bundles dependencies shared by all handlers.
type Server struct {
db *gorm.DB
store *storage.Storage
cfg config.Config
}
// NewRouter wires up the chi router and mounts the /api routes for every module.
func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config) http.Handler {
s := &Server{db: db, store: store, cfg: cfg}
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
ExposedHeaders: []string{"*"},
AllowCredentials: false,
MaxAge: 300,
}))
r.Route("/api", func(r chi.Router) {
// Public health for k8s probes.
r.Get("/health", s.handleHealth)
r.Group(func(r chi.Router) {
r.Use(authMiddleware(cfg.DevAuth, cfg.AdminGroups))
// identity / navigation
r.Get("/me", s.handleMe)
r.Get("/me/nav", s.handleNav)
// ---- slice 1: members / org ----
r.Get("/members", s.handleListMembers)
r.Post("/members", s.handleCreateMember)
r.Get("/members/{id}", s.handleGetMember)
r.Patch("/members/{id}", s.handlePatchMember)
r.Delete("/members/{id}", s.handleDeleteMember)
r.Get("/departments", s.handleListDepartments)
r.Post("/departments", s.handleCreateDepartment)
r.Patch("/departments/{id}", s.handlePatchDepartment)
r.Delete("/departments/{id}", s.handleDeleteDepartment)
r.Get("/audit", s.handleListAudit)
// ---- slice 2: attendance / leave ----
r.Get("/attendance", s.handleListAttendance) // own (admin: ?email= or all)
r.Post("/attendance/punch", s.handlePunch) // clock in/out
r.Get("/attendance/timesheet", s.handleTimesheet) // monthly roll-up
r.Get("/leave", s.handleListLeave)
r.Post("/leave", s.handleCreateLeave)
r.Post("/leave/{id}/decide", s.handleDecideLeave) // admin approve/reject
r.Post("/leave/{id}/cancel", s.handleCancelLeave)
r.Get("/overtime", s.handleListOvertime)
r.Post("/overtime", s.handleCreateOvertime)
r.Post("/overtime/{id}/decide", s.handleDecideOvertime)
r.Get("/work-policy", s.handleGetWorkPolicy)
r.Put("/work-policy", s.handlePutWorkPolicy)
r.Get("/approvals", s.handleApprovalQueue) // admin queue
// ---- slice 3: projects ----
r.Get("/companies", s.handleListCompanies)
r.Post("/companies", s.handleCreateCompany)
r.Get("/products", s.handleListProducts)
r.Post("/products", s.handleCreateProduct)
r.Get("/versions", s.handleListVersions)
r.Post("/versions", s.handleCreateVersion)
r.Get("/projects", s.handleListProjects)
r.Post("/projects", s.handleCreateProject)
r.Get("/projects/{id}", s.handleGetProject)
r.Patch("/projects/{id}", s.handlePatchProject)
r.Delete("/projects/{id}", s.handleDeleteProject)
r.Get("/projects/{id}/members", s.handleListProjectMembers)
r.Post("/projects/{id}/members", s.handleUpsertProjectMember)
r.Delete("/project-members/{pmId}", s.handleDeleteProjectMember)
r.Get("/projects/{id}/contacts", s.handleListContacts)
r.Post("/projects/{id}/contacts", s.handleUpsertContact)
r.Delete("/contacts/{cId}", s.handleDeleteContact)
r.Get("/projects/{id}/tasks", s.handleListTasks)
r.Post("/projects/{id}/tasks", s.handleCreateTask)
r.Patch("/tasks/{tId}", s.handlePatchTask)
r.Delete("/tasks/{tId}", s.handleDeleteTask)
// admin-only commercial block
r.Get("/projects/{id}/contract", s.handleGetContract)
r.Put("/projects/{id}/contract", s.handlePutContract)
r.Get("/projects/{id}/files", s.handleListContractFiles)
r.Post("/projects/{id}/files", s.handleUploadContractFile)
r.Get("/files/{fId}/download", s.handleDownloadContractFile)
r.Delete("/files/{fId}", s.handleDeleteContractFile)
r.Get("/projects/{id}/payments", s.handleListPayments)
r.Post("/projects/{id}/payments", s.handleCreatePayment)
r.Patch("/payments/{payId}", s.handlePatchPayment)
r.Delete("/payments/{payId}", s.handleDeletePayment)
// ---- slice 4: incentive ----
r.Get("/incentive/config", s.handleGetIncentiveConfig)
r.Put("/incentive/config", s.handlePutIncentiveConfig)
r.Get("/incentive/me", s.handleMyIncentive) // member dashboard
r.Get("/incentive/stages", s.handleListStages) // ?projectId=
r.Post("/incentive/projects/{id}/recompute", s.handleRecomputeProject)
r.Post("/incentive/stages/{stId}/status", s.handleSetStageStatus)
r.Get("/incentive/user-incentives", s.handleListUserIncentives)
r.Patch("/incentive/user-incentives/{uiId}", s.handlePatchUserIncentive)
r.Get("/incentive/settlements", s.handleListSettlements)
r.Post("/incentive/settlements/run", s.handleRunSettlement)
r.Post("/incentive/settlements/{sId}/fix", s.handleFixSettlement)
r.Post("/incentive/simulate", s.handleSimulate)
// ---- slice 5: accounting ----
r.Get("/accounts", s.handleListAccounts)
r.Post("/accounts", s.handleCreateAccount)
r.Get("/transactions", s.handleListTransactions)
r.Post("/transactions", s.handleCreateTransaction)
r.Patch("/transactions/{txId}", s.handlePatchTransaction)
r.Delete("/transactions/{txId}", s.handleDeleteTransaction)
r.Get("/taxes", s.handleListTaxes)
r.Post("/taxes", s.handleCreateTax)
r.Patch("/taxes/{taxId}", s.handlePatchTax)
r.Get("/accounting/summary", s.handleAccountingSummary) // cashflow vs incentive gap
// ---- slice 6: dashboard ----
r.Get("/dashboard", s.handleDashboard)
})
})
return r
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// --- shared helpers ---------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func decodeJSON(r *http.Request, v interface{}) error {
return json.NewDecoder(r.Body).Decode(v)
}