- 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:
co-authored by
Claude Opus 4.8
parent
ce4023ca59
commit
f83724b995
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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["주임"])
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user