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}) } // ---- 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} 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) }