feat: 인턴 직급 + 초과근무 관리자 집계화 + SSO 로그아웃 URL + 디바이스/FCM
build-and-push / build (push) Successful in 35s
build-and-push / build (push) Successful in 35s
- 직급에 인턴 추가(기본 할당량 15), 직책(position)은 UI에서 제거(컬럼은 유지) - 초과근무: 유저 신청 제거 → 관리자 근무관리에서 실제 출퇴근 기록 기반 자동 집계 - 로그아웃: infra 공통 LOGOUT_URL(/me로 전달) 사용 → oauth2-proxy 종료 + Keycloak end-session - (이전 커밋 포함) Device 등록 + FCM HTTP v1 sender + notify 연동 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
6158888417
commit
df09d23662
@@ -9,17 +9,19 @@ import (
|
||||
|
||||
// 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"`
|
||||
User User `json:"user"`
|
||||
Member *models.Member `json:"member"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
LogoutURL string `json:"logoutUrl"`
|
||||
}
|
||||
|
||||
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),
|
||||
User: u,
|
||||
Member: s.lookupMember(u.Email),
|
||||
IsAdmin: s.isAdmin(r),
|
||||
LogoutURL: s.cfg.LogoutURL,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -104,12 +104,13 @@ func (s *Server) handleTimesheet(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
// Overtime is OBSERVED from real attendance (worked beyond the daily standard),
|
||||
// not user-applied — only admins see it (via the 근무 관리 timesheet).
|
||||
otMin := 0
|
||||
for _, o := range ots {
|
||||
otMin += o.Minutes
|
||||
for _, a := range att {
|
||||
if extra := a.WorkMinutes - pol.DailyStandardMin; extra > 0 {
|
||||
otMin += extra
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, worktime.Compute(year, month, pol.DailyStandardMin, worked, leaveMin, otMin, days))
|
||||
|
||||
@@ -46,6 +46,41 @@ func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- push device registration (spin-mobile) -------------------------------
|
||||
|
||||
func (s *Server) handleRegisterDevice(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil || strings.TrimSpace(body.Token) == "" {
|
||||
writeError(w, http.StatusBadRequest, "token이 필요합니다")
|
||||
return
|
||||
}
|
||||
email := s.email(r)
|
||||
var d models.Device
|
||||
if err := s.db.Where("token = ?", body.Token).First(&d).Error; err == nil {
|
||||
d.MemberEmail = email
|
||||
d.Platform = body.Platform
|
||||
s.db.Save(&d)
|
||||
} else {
|
||||
d = models.Device{MemberEmail: email, Token: body.Token, Platform: body.Platform}
|
||||
s.db.Create(&d)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnregisterDevice(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
decodeJSON(r, &body)
|
||||
if body.Token != "" {
|
||||
s.db.Where("token = ?", body.Token).Delete(&models.Device{})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- work status (출근/퇴근/휴식/미팅/이동) --------------------------------
|
||||
|
||||
var workStatuses = map[string]bool{"in": true, "out": true, "break": true, "meeting": true, "move": true}
|
||||
|
||||
@@ -29,7 +29,7 @@ func (s *Server) incentiveConfig(year int) (models.IncentiveConfig, incentive.Co
|
||||
|
||||
func defaultQuota() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
models.RankJunior: 30.0, models.RankSenior: 50.0,
|
||||
models.RankIntern: 15.0, models.RankJunior: 30.0, models.RankSenior: 50.0,
|
||||
models.RankLead: 80.0, models.RankPartner: 120.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -84,12 +85,20 @@ func (s *Server) ensureMember(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// notify writes an inbox Notification (best-effort).
|
||||
// notify writes an inbox Notification and fans out a push to the recipient's
|
||||
// registered devices (best-effort; push is a no-op when FCM isn't configured).
|
||||
func (s *Server) notify(recipient, typ, title, body, link string) {
|
||||
if strings.TrimSpace(recipient) == "" {
|
||||
return
|
||||
}
|
||||
s.db.Create(&models.Notification{Recipient: recipient, Type: typ, Title: title, Body: body, Link: link})
|
||||
if s.push != nil && s.push.Enabled() {
|
||||
var tokens []string
|
||||
s.db.Model(&models.Device{}).Where("lower(member_email) = lower(?)", recipient).Pluck("token", &tokens)
|
||||
if len(tokens) > 0 {
|
||||
go s.push.Send(context.Background(), tokens, title, body, link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// audit writes an AuditLog row (best-effort).
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"spin/internal/config"
|
||||
"spin/internal/push"
|
||||
"spin/internal/storage"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -18,11 +19,12 @@ 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) http.Handler {
|
||||
s := &Server{db: db, store: store, cfg: cfg}
|
||||
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)
|
||||
@@ -52,6 +54,10 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config) http.Hand
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user