feat: 메일함·근무상태 기록·프로필 사진·자동 프로비저닝 + 인센티브 유저 노출 제한
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:
theorose49 2026-06-28 09:38:33 +09:00
parent f83724b995
commit a904cbf9b9
10 changed files with 339 additions and 6 deletions

View File

@ -63,9 +63,10 @@ func Load() Config {
S3AccessKey: firstEnv("minioadmin", "AWS_ACCESS_KEY_ID", "S3_ACCESS_KEY"), S3AccessKey: firstEnv("minioadmin", "AWS_ACCESS_KEY_ID", "S3_ACCESS_KEY"),
S3SecretKey: firstEnv("minioadmin", "AWS_SECRET_ACCESS_KEY", "S3_SECRET_KEY"), S3SecretKey: firstEnv("minioadmin", "AWS_SECRET_ACCESS_KEY", "S3_SECRET_KEY"),
DevAuth: env("DEV_AUTH", "true") != "false", DevAuth: env("DEV_AUTH", "true") != "false",
// Sample data is seeded only when SEED is truthy (default). Production // Sample data is seeded ONLY when SEED=true is explicitly set. Default is
// sets SEED=false so the cluster DB stays clean. // OFF so production never seeds (avoids confusion); local docker-compose /
SeedData: env("SEED", "true") != "false", // `make be-dev` opt in with SEED=true.
SeedData: env("SEED", "false") == "true",
// Super-admin Keycloak groups (comma-separated). Default: admin // Super-admin Keycloak groups (comma-separated). Default: admin
// (shared group name across all internal apps, not app-specific). // (shared group name across all internal apps, not app-specific).
AdminGroups: splitCSV(env("ADMIN_GROUPS", "admin")), AdminGroups: splitCSV(env("ADMIN_GROUPS", "admin")),

View File

@ -35,10 +35,14 @@ type NavItem struct {
var navItems = []NavItem{ var navItems = []NavItem{
{Key: "dashboard", Label: "대시보드", Path: "/", Icon: "LayoutDashboard", Section: "개요"}, {Key: "dashboard", Label: "대시보드", Path: "/", Icon: "LayoutDashboard", Section: "개요"},
{Key: "inbox", Label: "메일함", Path: "/inbox", Icon: "Inbox", Section: "개요"},
{Key: "attendance", Label: "근무", Path: "/attendance", Icon: "Clock", Section: "나의 업무"}, {Key: "attendance", Label: "근무", Path: "/attendance", Icon: "Clock", Section: "나의 업무"},
{Key: "projects", Label: "프로젝트", Path: "/projects", Icon: "FolderKanban", Section: "나의 업무"}, {Key: "projects", Label: "프로젝트", Path: "/projects", Icon: "FolderKanban", Section: "나의 업무"},
{Key: "incentive", Label: "인센티브", Path: "/incentive", Icon: "Coins", 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: "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: "incentive-admin", Label: "인센티브 관리", Path: "/admin/incentive", Icon: "Calculator", AdminOnly: true, Section: "관리자"},
{Key: "accounting", Label: "회계", Path: "/admin/accounting", Icon: "Wallet", 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: "관리자"}, {Key: "members", Label: "구성원", Path: "/admin/members", Icon: "Users", AdminOnly: true, Section: "관리자"},

View File

@ -179,6 +179,18 @@ type decision struct {
Memo string `json:"memo"` 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) { func (s *Server) handleDecideLeave(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) { if !s.requireAdmin(w, r) {
return return
@ -201,6 +213,12 @@ func (s *Server) handleDecideLeave(w http.ResponseWriter, r *http.Request) {
lv.DecisionMemo = d.Memo lv.DecisionMemo = d.Memo
s.db.Save(&lv) s.db.Save(&lv)
s.audit(r, "decide", "leave", lv.ID, lv.Status) 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) writeJSON(w, http.StatusOK, lv)
} }
@ -286,6 +304,11 @@ func (s *Server) handleDecideOvertime(w http.ResponseWriter, r *http.Request) {
o.DecisionMemo = d.Memo o.DecisionMemo = d.Memo
s.db.Save(&o) s.db.Save(&o)
s.audit(r, "decide", "overtime", o.ID, o.Status) 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) writeJSON(w, http.StatusOK, o)
} }

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

View File

@ -2,6 +2,7 @@ package httpapi
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@ -226,6 +227,18 @@ func (s *Server) handleSetStageStatus(w http.ResponseWriter, r *http.Request) {
s.db.Model(&models.UserIncentive{}). s.db.Model(&models.UserIncentive{}).
Where("stage_id = ? AND \"override\" = ?", st.ID, false).Updates(updates) Where("stage_id = ? AND \"override\" = ?", st.ID, false).Updates(updates)
s.audit(r, "stage_status", "payment_stage", st.ID, body.Status) 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) writeJSON(w, http.StatusOK, st)
} }
@ -408,6 +421,11 @@ func (s *Server) handleFixSettlement(w http.ResponseWriter, r *http.Request) {
st.FixedAt = &now st.FixedAt = &now
s.db.Save(&st) s.db.Save(&st)
s.audit(r, "settlement_fix", "settlement", st.ID, "") 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) writeJSON(w, http.StatusOK, st)
} }

View File

@ -92,7 +92,9 @@ func (s *Server) myProjectIDs(email string) []string {
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
q := s.db.Order("created_at desc") 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)) ids := s.myProjectIDs(s.email(r))
if len(ids) == 0 { if len(ids) == 0 {
writeJSON(w, http.StatusOK, []models.Project{}) writeJSON(w, http.StatusOK, []models.Project{})
@ -213,6 +215,11 @@ func (s *Server) handleUpsertProjectMember(w http.ResponseWriter, r *http.Reques
s.db.Save(&pm) s.db.Save(&pm)
} else { } else {
s.db.Create(&pm) 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) writeJSON(w, http.StatusOK, pm)
} }

View File

@ -66,6 +66,32 @@ func (s *Server) lookupMember(email string) *models.Member {
return &m 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). // audit writes an AuditLog row (best-effort).
func (s *Server) audit(r *http.Request, action, entity, entityID, detail string) { func (s *Server) audit(r *http.Request, action, entity, entityID, detail string) {
s.db.Create(&models.AuditLog{ s.db.Create(&models.AuditLog{

View File

@ -44,15 +44,25 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config) http.Hand
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authMiddleware(cfg.DevAuth, cfg.AdminGroups)) r.Use(authMiddleware(cfg.DevAuth, cfg.AdminGroups))
// auto-provision a Member on first login (Keycloak), then continue.
r.Use(s.ensureMember)
// identity / navigation // identity / navigation
r.Get("/me", s.handleMe) r.Get("/me", s.handleMe)
r.Get("/me/nav", s.handleNav) 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 ---- // ---- slice 1: members / org ----
r.Get("/members", s.handleListMembers) r.Get("/members", s.handleListMembers)
r.Post("/members", s.handleCreateMember) r.Post("/members", s.handleCreateMember)
r.Get("/members/{id}", s.handleGetMember) r.Get("/members/{id}", s.handleGetMember)
r.Get("/members/{id}/avatar", s.handleGetAvatar)
r.Patch("/members/{id}", s.handlePatchMember) r.Patch("/members/{id}", s.handlePatchMember)
r.Delete("/members/{id}", s.handleDeleteMember) r.Delete("/members/{id}", s.handleDeleteMember)
r.Get("/departments", s.handleListDepartments) 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 ---- // ---- slice 2: attendance / leave ----
r.Get("/attendance", s.handleListAttendance) // own (admin: ?email= or all) r.Get("/attendance", s.handleListAttendance) // own (admin: ?email= or all)
r.Post("/attendance/punch", s.handlePunch) // clock in/out 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", s.handleListLeave)
r.Get("/leave/balance", s.handleLeaveBalance) // 남은 연차(소수점)
r.Post("/leave", s.handleCreateLeave) r.Post("/leave", s.handleCreateLeave)
r.Post("/leave/{id}/decide", s.handleDecideLeave) // admin approve/reject r.Post("/leave/{id}/decide", s.handleDecideLeave) // admin approve/reject
r.Post("/leave/{id}/cancel", s.handleCancelLeave) r.Post("/leave/{id}/cancel", s.handleCancelLeave)

View File

@ -39,10 +39,14 @@ type Member struct {
Status string `json:"status"` // active | inactive Status string `json:"status"` // active | inactive
JoinDate *time.Time `json:"joinDate"` JoinDate *time.Time `json:"joinDate"`
AnnualLeave float64 `json:"annualLeave"` // granted 연차 days for the year 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"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` 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 } func (m *Member) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
// Department is an org unit. Lightweight; the lead is a Member email. // 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 } 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 }

View File

@ -23,7 +23,7 @@ func (b *Base) ensureID() {
func All() []interface{} { func All() []interface{} {
return []interface{}{ return []interface{}{
// slice 1 — members / org // slice 1 — members / org
&Member{}, &Department{}, &AuditLog{}, &Member{}, &Department{}, &AuditLog{}, &Notification{}, &WorkStatusEvent{},
// slice 2 — attendance / leave // slice 2 — attendance / leave
&Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{}, &Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{},
// slice 3 — projects // slice 3 — projects