package httpapi import ( "encoding/json" "net/http" "spin/internal/config" "spin/internal/mailsync" "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 mail *mailsync.Service } // 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, mailer *mailsync.Service) http.Handler { s := &Server{db: db, store: store, cfg: cfg, push: pusher, mail: mailer} 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.Patch("/projects/{id}/notes", s.handlePatchProjectNotes) r.Get("/my/tasks", s.handleMyTasks) r.Get("/calendar/events", s.handleListEvents) r.Post("/calendar/events", s.handleCreateEvent) r.Patch("/calendar/events/{eId}", s.handlePatchEvent) r.Delete("/calendar/events/{eId}", s.handleDeleteEvent) 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) // project mail (Google Workspace 도메인 위임) + 공동 메모 r.Get("/projects/{id}/mails", s.handleListProjectMails) r.Get("/projects/{id}/mails/full", s.handleMailFull) r.Get("/projects/{id}/mails/attachment", s.handleMailAttachment) r.Post("/projects/{id}/mails/sync", s.handleSyncProjectMail) r.Put("/projects/{id}/mail-hide", s.handleHideMail) r.Get("/projects/{id}/mail-notes", s.handleListMailNotes) r.Put("/projects/{id}/mail-notes", s.handlePutMailNote) // 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) }