All checks were successful
build-and-push / build (push) Successful in 33s
- 알림(Notification) 모델/이벤트 발행(프로젝트 추가·휴가/초과근무 승인·인센티브 반영/지급·정산 확정) + 메일함 API - 근무상태 기록(WorkStatusEvent: 출근/퇴근/휴식/미팅/이동), 출퇴근은 Attendance도 갱신 - 남은 연차(소수점) 엔드포인트, 관리자 근무관리용 집계/로그 조회 - 프로필 사진(Member.AvatarKey) 업로드/스트리밍 - Keycloak 최초 로그인 자동 Member 프로비저닝(ensureMember, rank/부서 nullable) - 프로젝트 scope=mine(나의 업무는 관리자도 본인 참여분만), nav에 메일함·근무관리·프로젝트관리·내프로필 추가 - 운영 안전: SEED 기본값 false(로컬만 SEED=true), ADMIN_GROUPS 기본 'admin' Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
179 lines
7.0 KiB
Go
179 lines
7.0 KiB
Go
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))
|
|
// 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)
|
|
|
|
// 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.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.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)
|
|
}
|