feat: 메일함·근무상태 기록·프로필 사진·자동 프로비저닝 + 인센티브 유저 노출 제한
All checks were successful
build-and-push / build (push) Successful in 33s
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>
This commit is contained in:
parent
f83724b995
commit
a904cbf9b9
@ -63,9 +63,10 @@ func Load() Config {
|
||||
S3AccessKey: firstEnv("minioadmin", "AWS_ACCESS_KEY_ID", "S3_ACCESS_KEY"),
|
||||
S3SecretKey: firstEnv("minioadmin", "AWS_SECRET_ACCESS_KEY", "S3_SECRET_KEY"),
|
||||
DevAuth: env("DEV_AUTH", "true") != "false",
|
||||
// Sample data is seeded only when SEED is truthy (default). Production
|
||||
// sets SEED=false so the cluster DB stays clean.
|
||||
SeedData: env("SEED", "true") != "false",
|
||||
// Sample data is seeded ONLY when SEED=true is explicitly set. Default is
|
||||
// OFF so production never seeds (avoids confusion); local docker-compose /
|
||||
// `make be-dev` opt in with SEED=true.
|
||||
SeedData: env("SEED", "false") == "true",
|
||||
// Super-admin Keycloak groups (comma-separated). Default: admin
|
||||
// (shared group name across all internal apps, not app-specific).
|
||||
AdminGroups: splitCSV(env("ADMIN_GROUPS", "admin")),
|
||||
|
||||
@ -35,10 +35,14 @@ type NavItem struct {
|
||||
|
||||
var navItems = []NavItem{
|
||||
{Key: "dashboard", Label: "대시보드", Path: "/", Icon: "LayoutDashboard", Section: "개요"},
|
||||
{Key: "inbox", Label: "메일함", Path: "/inbox", Icon: "Inbox", Section: "개요"},
|
||||
{Key: "attendance", Label: "근무", Path: "/attendance", Icon: "Clock", Section: "나의 업무"},
|
||||
{Key: "projects", Label: "프로젝트", Path: "/projects", Icon: "FolderKanban", Section: "나의 업무"},
|
||||
{Key: "incentive", Label: "인센티브", Path: "/incentive", Icon: "Coins", Section: "나의 업무"},
|
||||
{Key: "profile", Label: "내 프로필", Path: "/profile", Icon: "UserCircle", Section: "나의 업무"},
|
||||
{Key: "approvals", Label: "승인 관리", Path: "/admin/approvals", Icon: "CheckSquare", AdminOnly: true, Section: "관리자"},
|
||||
{Key: "attendance-admin", Label: "근무 관리", Path: "/admin/attendance", Icon: "ClipboardList", AdminOnly: true, Section: "관리자"},
|
||||
{Key: "projects-admin", Label: "프로젝트 관리", Path: "/admin/projects", Icon: "FolderCog", AdminOnly: true, Section: "관리자"},
|
||||
{Key: "incentive-admin", Label: "인센티브 관리", Path: "/admin/incentive", Icon: "Calculator", AdminOnly: true, Section: "관리자"},
|
||||
{Key: "accounting", Label: "회계", Path: "/admin/accounting", Icon: "Wallet", AdminOnly: true, Section: "관리자"},
|
||||
{Key: "members", Label: "구성원", Path: "/admin/members", Icon: "Users", AdminOnly: true, Section: "관리자"},
|
||||
|
||||
@ -179,6 +179,18 @@ type decision struct {
|
||||
Memo string `json:"memo"`
|
||||
}
|
||||
|
||||
var leaveLabels = map[string]string{
|
||||
models.LeaveAnnual: "연차", models.LeaveHalfAM: "오전 반차", models.LeaveHalfPM: "오후 반차",
|
||||
models.LeavePublic: "공가", models.LeaveSick: "병가", models.LeaveFamily: "경조사", models.LeaveUnpaid: "무급",
|
||||
}
|
||||
|
||||
func leaveTypeLabel(t string) string {
|
||||
if l, ok := leaveLabels[t]; ok {
|
||||
return l
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (s *Server) handleDecideLeave(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
@ -201,6 +213,12 @@ func (s *Server) handleDecideLeave(w http.ResponseWriter, r *http.Request) {
|
||||
lv.DecisionMemo = d.Memo
|
||||
s.db.Save(&lv)
|
||||
s.audit(r, "decide", "leave", lv.ID, lv.Status)
|
||||
verdict := "반려되었습니다"
|
||||
if d.Approve {
|
||||
verdict = "승인되었습니다"
|
||||
}
|
||||
s.notify(lv.MemberEmail, "leave", "휴가 신청이 "+verdict,
|
||||
"["+leaveTypeLabel(lv.Type)+"] "+lv.StartDate+" 신청이 "+verdict+".", "/attendance")
|
||||
writeJSON(w, http.StatusOK, lv)
|
||||
}
|
||||
|
||||
@ -286,6 +304,11 @@ func (s *Server) handleDecideOvertime(w http.ResponseWriter, r *http.Request) {
|
||||
o.DecisionMemo = d.Memo
|
||||
s.db.Save(&o)
|
||||
s.audit(r, "decide", "overtime", o.ID, o.Status)
|
||||
verdict := "반려되었습니다"
|
||||
if d.Approve {
|
||||
verdict = "승인되었습니다"
|
||||
}
|
||||
s.notify(o.MemberEmail, "overtime", "초과근무 신청이 "+verdict, o.Date+" 초과근무 신청이 "+verdict+".", "/attendance")
|
||||
writeJSON(w, http.StatusOK, o)
|
||||
}
|
||||
|
||||
|
||||
210
internal/httpapi/handlers_inbox.go
Normal file
210
internal/httpapi/handlers_inbox.go
Normal file
@ -0,0 +1,210 @@
|
||||
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)
|
||||
}
|
||||
@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@ -226,6 +227,18 @@ func (s *Server) handleSetStageStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.db.Model(&models.UserIncentive{}).
|
||||
Where("stage_id = ? AND \"override\" = ?", st.ID, false).Updates(updates)
|
||||
s.audit(r, "stage_status", "payment_stage", st.ID, body.Status)
|
||||
// notify affected members on 반영완료/지급완료 (BE/non-BE 등 내부 개념은 노출하지 않음)
|
||||
if body.Status == models.FixApplied || body.Status == models.FixPaid {
|
||||
msg := "인센티브 포인트가 반영되었습니다."
|
||||
if body.Status == models.FixPaid {
|
||||
msg = "인센티브가 지급되었습니다."
|
||||
}
|
||||
var emails []string
|
||||
s.db.Model(&models.UserIncentive{}).Where("stage_id = ?", st.ID).Distinct().Pluck("member_email", &emails)
|
||||
for _, e := range emails {
|
||||
s.notify(e, "incentive", "인센티브 업데이트", msg, "/incentive")
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
@ -408,6 +421,11 @@ func (s *Server) handleFixSettlement(w http.ResponseWriter, r *http.Request) {
|
||||
st.FixedAt = &now
|
||||
s.db.Save(&st)
|
||||
s.audit(r, "settlement_fix", "settlement", st.ID, "")
|
||||
if st.PayoutPoints > 0 {
|
||||
s.notify(st.MemberEmail, "settlement",
|
||||
fmt.Sprintf("%d년 %d분기 인센티브 정산 확정", st.Year, st.Quarter),
|
||||
"이번 분기 인센티브가 확정되었습니다. 내 인센티브에서 확인하세요.", "/incentive")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
|
||||
@ -92,7 +92,9 @@ func (s *Server) myProjectIDs(email string) []string {
|
||||
|
||||
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
q := s.db.Order("created_at desc")
|
||||
if !s.isAdmin(r) {
|
||||
// Non-admins always see only their own projects. Admins see all by default,
|
||||
// but the "나의 업무" view passes ?scope=mine to get the same own-only list.
|
||||
if !s.isAdmin(r) || r.URL.Query().Get("scope") == "mine" {
|
||||
ids := s.myProjectIDs(s.email(r))
|
||||
if len(ids) == 0 {
|
||||
writeJSON(w, http.StatusOK, []models.Project{})
|
||||
@ -213,6 +215,11 @@ func (s *Server) handleUpsertProjectMember(w http.ResponseWriter, r *http.Reques
|
||||
s.db.Save(&pm)
|
||||
} else {
|
||||
s.db.Create(&pm)
|
||||
var proj models.Project
|
||||
s.db.First(&proj, "id = ?", pm.ProjectID)
|
||||
s.notify(pm.MemberEmail, "project", "프로젝트에 추가되었습니다",
|
||||
fmt.Sprintf("'%s' 프로젝트에 작업자로 추가되었습니다. (기여도 %g%%)", proj.Name, pm.Portion),
|
||||
"/projects/"+pm.ProjectID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pm)
|
||||
}
|
||||
|
||||
@ -66,6 +66,32 @@ func (s *Server) lookupMember(email string) *models.Member {
|
||||
return &m
|
||||
}
|
||||
|
||||
// ensureMember auto-provisions a spin Member the first time an authenticated
|
||||
// identity is seen (Keycloak first login). rank/department stay empty (nullable)
|
||||
// for an admin to fill in later. Account lifecycle itself remains Keycloak's job.
|
||||
func (s *Server) ensureMember(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r.Context())
|
||||
if e := strings.TrimSpace(u.Email); e != "" && s.lookupMember(e) == nil {
|
||||
s.db.Create(&models.Member{
|
||||
Email: e,
|
||||
DisplayName: firstNonEmpty(u.Name, e),
|
||||
Role: models.RoleMember,
|
||||
Status: "active",
|
||||
})
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// notify writes an inbox Notification (best-effort).
|
||||
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})
|
||||
}
|
||||
|
||||
// audit writes an AuditLog row (best-effort).
|
||||
func (s *Server) audit(r *http.Request, action, entity, entityID, detail string) {
|
||||
s.db.Create(&models.AuditLog{
|
||||
|
||||
@ -44,15 +44,25 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config) http.Hand
|
||||
|
||||
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)
|
||||
|
||||
// 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.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)
|
||||
@ -64,8 +74,11 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config) http.Hand
|
||||
// ---- slice 2: attendance / leave ----
|
||||
r.Get("/attendance", s.handleListAttendance) // own (admin: ?email= or all)
|
||||
r.Post("/attendance/punch", s.handlePunch) // clock in/out
|
||||
r.Get("/attendance/timesheet", s.handleTimesheet) // monthly roll-up
|
||||
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)
|
||||
|
||||
@ -39,10 +39,14 @@ type Member struct {
|
||||
Status string `json:"status"` // active | inactive
|
||||
JoinDate *time.Time `json:"joinDate"`
|
||||
AnnualLeave float64 `json:"annualLeave"` // granted 연차 days for the year
|
||||
AvatarKey string `json:"avatarKey"` // S3 key of profile photo (empty = none)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// HasAvatar reports whether a profile photo is set.
|
||||
func (m *Member) HasAvatar() bool { return m.AvatarKey != "" }
|
||||
|
||||
func (m *Member) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// Department is an org unit. Lightweight; the lead is a Member email.
|
||||
@ -68,3 +72,30 @@ type AuditLog struct {
|
||||
}
|
||||
|
||||
func (m *AuditLog) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// Notification is a per-member inbox event (프로젝트 추가·휴가 승인·인센티브 반영 등).
|
||||
type Notification struct {
|
||||
Base
|
||||
Recipient string `gorm:"index" json:"recipient"` // member email
|
||||
Type string `json:"type"` // project | leave | overtime | incentive | settlement
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Link string `json:"link"` // in-app path, e.g. /projects/{id}
|
||||
Read bool `gorm:"index" json:"read"`
|
||||
CreatedAt time.Time `gorm:"index" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (m *Notification) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// WorkStatusEvent logs a member's presence change (출근/퇴근/휴식/미팅/이동) for the
|
||||
// admin work-management timeline. 출근/퇴근 additionally update the Attendance row.
|
||||
type WorkStatusEvent struct {
|
||||
Base
|
||||
MemberEmail string `gorm:"index" json:"memberEmail"`
|
||||
Date string `gorm:"index" json:"date"` // YYYY-MM-DD (KST)
|
||||
Status string `json:"status"` // in|out|break|meeting|move
|
||||
At time.Time `json:"at"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func (m *WorkStatusEvent) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
@ -23,7 +23,7 @@ func (b *Base) ensureID() {
|
||||
func All() []interface{} {
|
||||
return []interface{}{
|
||||
// slice 1 — members / org
|
||||
&Member{}, &Department{}, &AuditLog{},
|
||||
&Member{}, &Department{}, &AuditLog{}, &Notification{}, &WorkStatusEvent{},
|
||||
// slice 2 — attendance / leave
|
||||
&Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{},
|
||||
// slice 3 — projects
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user