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>
211 lines
6.5 KiB
Go
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)
|
|
}
|