theorose49 ce07aced0f
All checks were successful
build-and-push / build (push) Successful in 33s
feat(members): 전 구성원 공개 이름 디렉터리 엔드포인트
- GET /members/directory: 모든 인증 사용자에게 {id,email,displayName,avatarKey} 반환
- PM/작업자/담당자 등을 이메일이 아닌 이름으로 표시하기 위함 (직급/연락처 미포함)

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

196 lines
7.9 KiB
Go

package httpapi
import (
"encoding/json"
"net/http"
"spin/internal/config"
"spin/internal/push"
"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
push *push.Sender
}
// 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, pusher *push.Sender) http.Handler {
s := &Server{db: db, store: store, cfg: cfg, push: pusher}
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))
// auto-provision a Member on first login (Keycloak), then continue.
r.Use(s.ensureMember)
// identity / navigation
r.Get("/me", s.handleMe)
r.Get("/me/nav", s.handleNav)
r.Post("/me/avatar", s.handleUploadAvatar)
// push device registration (spin-mobile)
r.Post("/devices", s.handleRegisterDevice)
r.Post("/devices/unregister", s.handleUnregisterDevice)
// inbox / notifications (메일함)
r.Get("/notifications", s.handleListNotifications)
r.Get("/notifications/unread-count", s.handleUnreadCount)
r.Post("/notifications/{id}/read", s.handleMarkRead)
r.Post("/notifications/read-all", s.handleMarkAllRead)
// ---- slice 1: members / org ----
r.Get("/members", s.handleListMembers)
r.Get("/members/directory", s.handleMemberDirectory)
r.Post("/members", s.handleCreateMember)
r.Get("/members/{id}", s.handleGetMember)
r.Get("/members/{id}/avatar", s.handleGetAvatar)
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.Post("/attendance/status", s.handleSetWorkStatus) // 출근/퇴근/휴식/미팅/이동
r.Get("/attendance/status", s.handleListWorkStatus) // presence log (admin: all/?email=)
r.Get("/attendance/timesheet", s.handleTimesheet) // monthly roll-up (admin mgmt)
r.Get("/leave", s.handleListLeave)
r.Get("/leave/balance", s.handleLeaveBalance) // 남은 연차(소수점)
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.Patch("/companies/{id}", s.handlePatchCompany)
r.Delete("/companies/{id}", s.handleDeleteCompany)
r.Get("/products", s.handleListProducts)
r.Post("/products", s.handleCreateProduct)
r.Patch("/products/{id}", s.handlePatchProduct)
r.Delete("/products/{id}", s.handleDeleteProduct)
r.Get("/versions", s.handleListVersions)
r.Post("/versions", s.handleCreateVersion)
r.Patch("/versions/{id}", s.handlePatchVersion)
r.Delete("/versions/{id}", s.handleDeleteVersion)
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)
r.Get("/tasks/{tId}/comments", s.handleListTaskComments)
r.Post("/tasks/{tId}/comments", s.handleCreateTaskComment)
r.Delete("/comments/{cId}", s.handleDeleteTaskComment)
// 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.Delete("/taxes/{taxId}", s.handleDeleteTax)
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)
}