From a904cbf9b97fdd7007e2db76c0d846719d7f9706 Mon Sep 17 00:00:00 2001 From: theorose49 Date: Sun, 28 Jun 2026 09:38:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=BC=ED=95=A8=C2=B7?= =?UTF-8?q?=EA=B7=BC=EB=AC=B4=EC=83=81=ED=83=9C=20=EA=B8=B0=EB=A1=9D=C2=B7?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=82=AC=EC=A7=84=C2=B7=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=ED=94=84=EB=A1=9C=EB=B9=84=EC=A0=80=EB=8B=9D=20+?= =?UTF-8?q?=20=EC=9D=B8=EC=84=BC=ED=8B=B0=EB=B8=8C=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림(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) --- internal/config/config.go | 7 +- internal/httpapi/handlers.go | 4 + internal/httpapi/handlers_attendance.go | 23 +++ internal/httpapi/handlers_inbox.go | 210 ++++++++++++++++++++++++ internal/httpapi/handlers_incentive.go | 18 ++ internal/httpapi/handlers_projects.go | 9 +- internal/httpapi/perms.go | 26 +++ internal/httpapi/router.go | 15 +- internal/models/member.go | 31 ++++ internal/models/models.go | 2 +- 10 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 internal/httpapi/handlers_inbox.go diff --git a/internal/config/config.go b/internal/config/config.go index 8cc0a15..7860ce2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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")), diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 90b5c3a..f42cea0 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -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: "관리자"}, diff --git a/internal/httpapi/handlers_attendance.go b/internal/httpapi/handlers_attendance.go index 9448bf2..201bd03 100644 --- a/internal/httpapi/handlers_attendance.go +++ b/internal/httpapi/handlers_attendance.go @@ -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) } diff --git a/internal/httpapi/handlers_inbox.go b/internal/httpapi/handlers_inbox.go new file mode 100644 index 0000000..c23a2cb --- /dev/null +++ b/internal/httpapi/handlers_inbox.go @@ -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) +} diff --git a/internal/httpapi/handlers_incentive.go b/internal/httpapi/handlers_incentive.go index d0474a2..08b6525 100644 --- a/internal/httpapi/handlers_incentive.go +++ b/internal/httpapi/handlers_incentive.go @@ -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) } diff --git a/internal/httpapi/handlers_projects.go b/internal/httpapi/handlers_projects.go index f85a346..ba72710 100644 --- a/internal/httpapi/handlers_projects.go +++ b/internal/httpapi/handlers_projects.go @@ -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) } diff --git a/internal/httpapi/perms.go b/internal/httpapi/perms.go index 5954434..5ef902f 100644 --- a/internal/httpapi/perms.go +++ b/internal/httpapi/perms.go @@ -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{ diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index e5fec85..b3e2a8b 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -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) diff --git a/internal/models/member.go b/internal/models/member.go index c156281..b76876f 100644 --- a/internal/models/member.go +++ b/internal/models/member.go @@ -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 } diff --git a/internal/models/models.go b/internal/models/models.go index 88fd6d6..286187d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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