spin-backend/internal/httpapi/handlers_inbox.go
theorose49 a904cbf9b9
All checks were successful
build-and-push / build (push) Successful in 33s
feat: 메일함·근무상태 기록·프로필 사진·자동 프로비저닝 + 인센티브 유저 노출 제한
- 알림(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>
2026-06-28 09:38:33 +09:00

211 lines
6.5 KiB
Go

package httpapi
import (
"fmt"
"io"
"net/http"
"path"
"strconv"
"strings"
"time"
"spin/internal/models"
"github.com/go-chi/chi/v5"
)
// ---- notifications (inbox / 메일함) ----------------------------------------
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
var out []models.Notification
q := s.db.Where("lower(recipient) = ?", s.email(r)).Order("created_at desc").Limit(200)
if r.URL.Query().Get("unread") == "true" {
q = q.Where("read = ?", false)
}
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleUnreadCount(w http.ResponseWriter, r *http.Request) {
var n int64
s.db.Model(&models.Notification{}).Where("lower(recipient) = ? AND read = ?", s.email(r), false).Count(&n)
writeJSON(w, http.StatusOK, map[string]int64{"count": n})
}
func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
s.db.Model(&models.Notification{}).
Where("id = ? AND lower(recipient) = ?", chi.URLParam(r, "id"), s.email(r)).
Update("read", true)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) {
s.db.Model(&models.Notification{}).
Where("lower(recipient) = ? AND read = ?", s.email(r), false).
Update("read", true)
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}
func (s *Server) handleSetWorkStatus(w http.ResponseWriter, r *http.Request) {
var body struct {
Status string `json:"status"`
Note string `json:"note"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if !workStatuses[body.Status] {
writeError(w, http.StatusBadRequest, "알 수 없는 상태")
return
}
email := s.email(r)
now := time.Now()
date := now.Format("2006-01-02")
s.db.Create(&models.WorkStatusEvent{MemberEmail: email, Date: date, Status: body.Status, At: now, Note: body.Note})
// 출근/퇴근은 Attendance 기록도 갱신
if body.Status == "in" || body.Status == "out" {
pol := s.activeWorkPolicy()
var a models.Attendance
err := s.db.Where("lower(member_email) = ? AND date = ?", email, date).First(&a).Error
if body.Status == "in" {
if err != nil {
s.db.Create(&models.Attendance{MemberEmail: email, Date: date, ClockIn: &now, Source: "status"})
} else if a.ClockIn == nil {
a.ClockIn = &now
s.db.Save(&a)
}
} else { // out
if err != nil {
s.db.Create(&models.Attendance{MemberEmail: email, Date: date, ClockOut: &now, Source: "status"})
} else {
a.ClockOut = &now
a.WorkMinutes = netMinutes(a.ClockIn, a.ClockOut, pol.LunchMinutes)
s.db.Save(&a)
}
}
}
writeJSON(w, http.StatusOK, map[string]string{"status": body.Status})
}
// netMinutes mirrors worktime.NetWorkMinutes (kept local to avoid import cycle churn).
func netMinutes(in, out *time.Time, lunch int) int {
if in == nil || out == nil {
return 0
}
m := int(out.Sub(*in).Minutes()) - lunch
if m < 0 {
return 0
}
return m
}
// handleListWorkStatus returns presence events. Members: own; admins: any (?email=) or all.
func (s *Server) handleListWorkStatus(w http.ResponseWriter, r *http.Request) {
email, all := s.scopeEmail(r)
q := s.db.Order("at desc").Limit(500)
if !all {
q = q.Where("lower(member_email) = ?", email)
}
if d := r.URL.Query().Get("date"); d != "" {
q = q.Where("date = ?", d)
}
var out []models.WorkStatusEvent
q.Find(&out)
writeJSON(w, http.StatusOK, out)
}
// ---- leave balance (남은 연차, 소수점) ------------------------------------
type leaveBalance struct {
Year int `json:"year"`
Granted float64 `json:"granted"`
Used float64 `json:"used"`
Remaining float64 `json:"remaining"`
}
func (s *Server) handleLeaveBalance(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)
granted := 15.0
if m := s.lookupMember(email); m != nil && m.AnnualLeave > 0 {
granted = m.AnnualLeave
}
var bal models.LeaveBalance
if err := s.db.Where("lower(member_email) = ? AND year = ?", email, year).First(&bal).Error; err == nil && bal.Granted > 0 {
granted = bal.Granted
}
// used = approved 연차/반차 days within the year (live)
var used float64
s.db.Model(&models.LeaveRequest{}).
Where("lower(member_email) = ? AND status = ? AND start_date LIKE ? AND type IN ?",
email, models.StatusApproved, strconv.Itoa(year)+"%",
[]string{models.LeaveAnnual, models.LeaveHalfAM, models.LeaveHalfPM}).
Select("COALESCE(SUM(days),0)").Scan(&used)
writeJSON(w, http.StatusOK, leaveBalance{Year: year, Granted: granted, Used: used, Remaining: granted - used})
}
// ---- avatar (프로필 사진) --------------------------------------------------
func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
m := s.lookupMember(s.email(r))
if m == nil {
writeError(w, http.StatusNotFound, "구성원 정보가 없습니다")
return
}
if err := r.ParseMultipartForm(10 << 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()
ext := strings.ToLower(path.Ext(hdr.Filename))
key := fmt.Sprintf("avatars/%s-%d%s", m.ID, time.Now().UnixNano(), ext)
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
}
}
m.AvatarKey = key
s.db.Model(m).Update("avatar_key", key)
writeJSON(w, http.StatusOK, m)
}
var imgMime = map[string]string{".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif"}
func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
var m models.Member
if err := s.db.First(&m, "id = ?", chi.URLParam(r, "id")).Error; err != nil || m.AvatarKey == "" || s.store == nil {
http.NotFound(w, r)
return
}
body, _, err := s.store.Get(r.Context(), m.AvatarKey)
if err != nil {
http.NotFound(w, r)
return
}
defer body.Close()
ct := imgMime[strings.ToLower(path.Ext(m.AvatarKey))]
if ct == "" {
ct = "application/octet-stream"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "private, max-age=60")
_, _ = io.Copy(w, body)
}