feat(mail): DB 저장 + 주기 동기화 + 전체 히스토리 수집 + 메일 숨김
All checks were successful
build-and-push / build (push) Successful in 33s
All checks were successful
build-and-push / build (push) Successful in 33s
- ProjectMailMsg(헤더 저장)·ProjectMailState(동기화 상태) 모델, AutoMigrate 등록 - mailsync.FetchForDomain: nextPageToken 따라 전체 히스토리 페이지네이션(maxPerBox=0=전부) - 백그라운드 주기 동기화 StartMailSyncLoop(MAIL_SYNC_INTERVAL 기본 15m, 0=비활성) · 미동기화 프로젝트=full 백필, 이후=최신 페이지 top-up - GET /mails는 DB에서 읽어 참여자(from/to/cc) 필터 + 공동 메모 인라인 결합 + lastSyncedAt - POST /mails/sync(강제 풀싱크), PUT /mail-hide(프로젝트 단위 숨김) - mailCache 제거(DB가 캐시), config MailSyncInterval Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b4b47e5ed1
commit
c865baccd2
@ -49,6 +49,7 @@ func main() {
|
||||
pusher := push.New(cfg.FCMCredentialsFile)
|
||||
mailer := mailsync.New(cfg.GoogleSACredentialsFile)
|
||||
router := httpapi.NewRouter(gdb, store, cfg, pusher, mailer)
|
||||
httpapi.StartMailSyncLoop(context.Background(), gdb, mailer, cfg.MailSyncInterval)
|
||||
|
||||
addr := ":" + cfg.Port
|
||||
srv := &http.Server{
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all runtime configuration loaded from environment variables.
|
||||
@ -31,6 +32,9 @@ type Config struct {
|
||||
// JSON with DOMAIN-WIDE DELEGATION (gmail.readonly) used to read project
|
||||
// client-domain mail. Empty = mail integration disabled.
|
||||
GoogleSACredentialsFile string
|
||||
// MailSyncInterval is how often the background mail sync runs (MAIL_SYNC_INTERVAL,
|
||||
// e.g. "15m"). 0 disables periodic sync (on-demand backfill still works).
|
||||
MailSyncInterval time.Duration
|
||||
// LogoutURL is the common SSO logout path injected by infra (LOGOUT_URL):
|
||||
// ends the oauth2-proxy session then redirects to Keycloak end-session.
|
||||
// The static frontend reads it via /me. Defaults to the infra-common value.
|
||||
@ -48,6 +52,15 @@ func env(key, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
// parseDur parses a Go duration string (e.g. "15m"); invalid/"0" yields 0 (disabled).
|
||||
func parseDur(s string) time.Duration {
|
||||
d, err := time.ParseDuration(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// firstEnv returns the first non-empty env var among keys, else def.
|
||||
func firstEnv(def string, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
@ -83,6 +96,7 @@ func Load() Config {
|
||||
AdminGroups: splitCSV(env("ADMIN_GROUPS", "admin")),
|
||||
FCMCredentialsFile: env("FCM_CREDENTIALS_FILE", ""),
|
||||
GoogleSACredentialsFile: env("GOOGLE_SA_CREDENTIALS_FILE", ""),
|
||||
MailSyncInterval: parseDur(env("MAIL_SYNC_INTERVAL", "15m")),
|
||||
LogoutURL: env("LOGOUT_URL",
|
||||
"/oauth2/sign_out?rd=https%3A%2F%2Fauth.special-partners.com%2Frealms%2Fsp%2Fprotocol%2Fopenid-connect%2Flogout"),
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@ -477,16 +478,16 @@ func (s *Server) handleDeleteTaskComment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// ---- project mail (Google Workspace 도메인 위임) + 공동 메모 -----------------
|
||||
|
||||
type mailCacheEntry struct {
|
||||
at time.Time
|
||||
msgs []mailsync.Message
|
||||
type mailItem struct {
|
||||
models.ProjectMailMsg
|
||||
Note string `json:"note"` // 공동 메모 본문
|
||||
NoteEditedBy string `json:"noteEditedBy"` // 메모 마지막 수정자
|
||||
}
|
||||
|
||||
const mailCacheTTL = 3 * time.Minute
|
||||
|
||||
// handleListProjectMails aggregates client-domain mail across the project team's
|
||||
// mailboxes (service account + domain-wide delegation). Disabled/empty-domain
|
||||
// projects return an empty list with flags so the UI can explain.
|
||||
// handleListProjectMails returns the stored client-domain mail for the project,
|
||||
// each with its shared memo, filtered to messages the requester is a party to
|
||||
// (from/to/cc). Hidden flag travels along so the UI can collapse hidden items.
|
||||
// A never-synced project triggers an async full backfill.
|
||||
func (s *Server) handleListProjectMails(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if !s.canSeeProject(r, id) {
|
||||
@ -501,50 +502,91 @@ func (s *Server) handleListProjectMails(w http.ResponseWriter, r *http.Request)
|
||||
resp := map[string]any{
|
||||
"enabled": s.mail.Enabled(),
|
||||
"domain": p.ClientDomain,
|
||||
"messages": []mailsync.Message{},
|
||||
"messages": []mailItem{},
|
||||
}
|
||||
if !s.mail.Enabled() || strings.TrimSpace(p.ClientDomain) == "" {
|
||||
if strings.TrimSpace(p.ClientDomain) == "" {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
// The cache holds the COMPANY-WIDE aggregate for the domain; we then filter
|
||||
// per-viewer below (a member only sees mail they're a party to).
|
||||
var agg []mailsync.Message
|
||||
if v, ok := s.mailCache.Load(id); ok {
|
||||
if e := v.(*mailCacheEntry); time.Since(e.at) < mailCacheTTL {
|
||||
agg = e.msgs
|
||||
}
|
||||
|
||||
var st models.ProjectMailState
|
||||
synced := s.db.First(&st, "project_id = ?", id).Error == nil
|
||||
if synced && st.LastSyncedAt != nil {
|
||||
resp["lastSyncedAt"] = st.LastSyncedAt
|
||||
}
|
||||
if agg == nil {
|
||||
// 전사 모든 구성원 메일함을 impersonate해 도메인 메일을 모은다(구성원은 여러
|
||||
// 프로젝트에 걸쳐 있으므로 전사 수집 후 프로젝트 도메인으로 필터하는 편이 단순).
|
||||
var mailboxes []string
|
||||
s.db.Model(&models.Member{}).Where("status <> ?", "inactive").Pluck("email", &mailboxes)
|
||||
msgs, err := s.mail.ListForDomain(r.Context(), mailboxes, p.ClientDomain, 200)
|
||||
if err != nil {
|
||||
resp["error"] = err.Error()
|
||||
}
|
||||
if msgs == nil {
|
||||
msgs = []mailsync.Message{}
|
||||
}
|
||||
agg = msgs
|
||||
s.mailCache.Store(id, &mailCacheEntry{at: time.Now(), msgs: agg})
|
||||
if st.LastError != "" {
|
||||
resp["error"] = st.LastError
|
||||
}
|
||||
// First ever view → kick off full backfill in the background (only if mail
|
||||
// integration is configured). Already-stored mail is shown regardless.
|
||||
if !synced && s.mail.Enabled() {
|
||||
resp["syncing"] = true
|
||||
go syncProjectMail(context.Background(), s.db, s.mail, id, true)
|
||||
}
|
||||
|
||||
var rows []models.ProjectMailMsg
|
||||
s.db.Where("project_id = ?", id).Order("ts desc").Find(&rows)
|
||||
notes := map[string]models.MailNote{}
|
||||
var nl []models.MailNote
|
||||
s.db.Where("project_id = ?", id).Find(&nl)
|
||||
for _, n := range nl {
|
||||
notes[n.MessageID] = n
|
||||
}
|
||||
// per-viewer visibility: only mail the requester sent/received/was CC'd on.
|
||||
me := s.email(r)
|
||||
visible := make([]mailsync.Message, 0, len(agg))
|
||||
for _, m := range agg {
|
||||
if m.Involves(me) {
|
||||
visible = append(visible, m)
|
||||
items := make([]mailItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
m := mailsync.Message{ID: row.MessageID, From: row.FromAddr, To: row.ToAddr, Cc: row.CcAddr}
|
||||
if !m.Involves(me) {
|
||||
continue
|
||||
}
|
||||
if len(visible) >= 80 {
|
||||
break
|
||||
it := mailItem{ProjectMailMsg: row}
|
||||
if n, ok := notes[row.MessageID]; ok {
|
||||
it.Note = n.Body
|
||||
it.NoteEditedBy = n.LastEditedBy
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
resp["messages"] = visible
|
||||
resp["messages"] = items
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleSyncProjectMail forces a full resync (async). Any project member may run it.
|
||||
func (s *Server) handleSyncProjectMail(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if !s.canSeeProject(r, id) {
|
||||
writeError(w, http.StatusForbidden, "권한이 없습니다")
|
||||
return
|
||||
}
|
||||
go syncProjectMail(context.Background(), s.db, s.mail, id, true)
|
||||
writeJSON(w, http.StatusAccepted, map[string]bool{"started": true})
|
||||
}
|
||||
|
||||
// handleHideMail toggles project-level visibility of one mail (declutter). Any
|
||||
// project member may hide/unhide; it applies for the whole project.
|
||||
func (s *Server) handleHideMail(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if !s.canSeeProject(r, id) {
|
||||
writeError(w, http.StatusForbidden, "권한이 없습니다")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
MessageID string `json:"messageId"`
|
||||
Hidden bool `json:"hidden"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil || strings.TrimSpace(in.MessageID) == "" {
|
||||
writeError(w, http.StatusBadRequest, "messageId가 필요합니다")
|
||||
return
|
||||
}
|
||||
by := ""
|
||||
if in.Hidden {
|
||||
by = s.email(r)
|
||||
}
|
||||
s.db.Model(&models.ProjectMailMsg{}).
|
||||
Where("project_id = ? AND message_id = ?", id, in.MessageID).
|
||||
Updates(map[string]interface{}{"hidden": in.Hidden, "hidden_by": by})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleListMailNotes(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if !s.canSeeProject(r, id) {
|
||||
|
||||
98
internal/httpapi/mail_sync.go
Normal file
98
internal/httpapi/mail_sync.go
Normal file
@ -0,0 +1,98 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"spin/internal/mailsync"
|
||||
"spin/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// syncInFlight prevents concurrent syncs of the same project.
|
||||
var syncInFlight sync.Map // projectID -> struct{}
|
||||
|
||||
// syncProjectMail pulls the project's client-domain mail across ALL active member
|
||||
// mailboxes (domain-wide delegation), upserts the headers into ProjectMailMsg, and
|
||||
// records sync state. full=true pages the entire history; otherwise just the newest
|
||||
// page per mailbox (cheap periodic top-up).
|
||||
func syncProjectMail(ctx context.Context, db *gorm.DB, mailer *mailsync.Service, projectID string, full bool) error {
|
||||
if _, busy := syncInFlight.LoadOrStore(projectID, struct{}{}); busy {
|
||||
return nil
|
||||
}
|
||||
defer syncInFlight.Delete(projectID)
|
||||
|
||||
var p models.Project
|
||||
if err := db.First(&p, "id = ?", projectID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
domain := strings.TrimSpace(p.ClientDomain)
|
||||
if !mailer.Enabled() || domain == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var mailboxes []string
|
||||
db.Model(&models.Member{}).Where("status <> ?", "inactive").Pluck("email", &mailboxes)
|
||||
|
||||
perBox := 100 // periodic top-up: newest page per mailbox
|
||||
if full {
|
||||
perBox = 0 // whole history
|
||||
}
|
||||
msgs, ferr := mailer.FetchForDomain(ctx, mailboxes, domain, perBox)
|
||||
|
||||
for _, m := range msgs {
|
||||
row := models.ProjectMailMsg{ProjectID: projectID, MessageID: m.ID}
|
||||
// Upsert by (project, message-id); never clobber Hidden/HiddenBy.
|
||||
db.Where("project_id = ? AND message_id = ?", projectID, m.ID).
|
||||
Assign(map[string]interface{}{
|
||||
"thread_id": m.ThreadID, "from_addr": m.From, "to_addr": m.To, "cc_addr": m.Cc,
|
||||
"subject": m.Subject, "snippet": m.Snippet, "date_hdr": m.Date,
|
||||
"mailbox": m.Mailbox, "ts": m.TS,
|
||||
}).
|
||||
FirstOrCreate(&row)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var cnt int64
|
||||
db.Model(&models.ProjectMailMsg{}).Where("project_id = ?", projectID).Count(&cnt)
|
||||
st := models.ProjectMailState{ProjectID: projectID, LastSyncedAt: &now, Count: int(cnt)}
|
||||
if ferr != nil {
|
||||
st.LastError = ferr.Error()
|
||||
}
|
||||
db.Save(&st)
|
||||
return ferr
|
||||
}
|
||||
|
||||
// StartMailSyncLoop periodically syncs every project that has a client domain.
|
||||
// Projects never synced get a full-history backfill; the rest get a cheap top-up.
|
||||
func StartMailSyncLoop(ctx context.Context, db *gorm.DB, mailer *mailsync.Service, interval time.Duration) {
|
||||
if !mailer.Enabled() || interval <= 0 {
|
||||
log.Printf("mailsync: periodic sync disabled (enabled=%v interval=%s)", mailer.Enabled(), interval)
|
||||
return
|
||||
}
|
||||
log.Printf("mailsync: periodic sync every %s", interval)
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
var projects []models.Project
|
||||
db.Where("client_domain <> ''").Find(&projects)
|
||||
for _, p := range projects {
|
||||
var st models.ProjectMailState
|
||||
full := db.First(&st, "project_id = ?", p.ID).Error != nil
|
||||
if err := syncProjectMail(ctx, db, mailer, p.ID, full); err != nil {
|
||||
log.Printf("mailsync: project %s sync error: %v", p.ID, err)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@ -3,7 +3,6 @@ package httpapi
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"spin/internal/config"
|
||||
"spin/internal/mailsync"
|
||||
@ -18,12 +17,11 @@ import (
|
||||
|
||||
// Server bundles dependencies shared by all handlers.
|
||||
type Server struct {
|
||||
db *gorm.DB
|
||||
store *storage.Storage
|
||||
cfg config.Config
|
||||
push *push.Sender
|
||||
mail *mailsync.Service
|
||||
mailCache sync.Map // projectID -> *mailCacheEntry (short-TTL aggregated mail)
|
||||
db *gorm.DB
|
||||
store *storage.Storage
|
||||
cfg config.Config
|
||||
push *push.Sender
|
||||
mail *mailsync.Service
|
||||
}
|
||||
|
||||
// NewRouter wires up the chi router and mounts the /api routes for every module.
|
||||
@ -133,6 +131,8 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *p
|
||||
r.Delete("/comments/{cId}", s.handleDeleteTaskComment)
|
||||
// project mail (Google Workspace 도메인 위임) + 공동 메모
|
||||
r.Get("/projects/{id}/mails", s.handleListProjectMails)
|
||||
r.Post("/projects/{id}/mails/sync", s.handleSyncProjectMail)
|
||||
r.Put("/projects/{id}/mail-hide", s.handleHideMail)
|
||||
r.Get("/projects/{id}/mail-notes", s.handleListMailNotes)
|
||||
r.Put("/projects/{id}/mail-notes", s.handlePutMailNote)
|
||||
// admin-only commercial block
|
||||
|
||||
@ -112,9 +112,10 @@ func New(credsPath string) *Service {
|
||||
// Enabled reports whether real Gmail access is configured.
|
||||
func (s *Service) Enabled() bool { return s != nil && s.sa != nil }
|
||||
|
||||
// ListForDomain impersonates each mailbox in turn, searches it for the client
|
||||
// domain, and returns the aggregated, de-duplicated, newest-first list (<= max).
|
||||
func (s *Service) ListForDomain(ctx context.Context, mailboxes []string, domain string, max int) ([]Message, error) {
|
||||
// FetchForDomain impersonates each mailbox in turn and pages through its full
|
||||
// history for the client domain (maxPerBox caps pages per mailbox; <=0 = all),
|
||||
// returning the aggregated, de-duplicated, newest-first list.
|
||||
func (s *Service) FetchForDomain(ctx context.Context, mailboxes []string, domain string, maxPerBox int) ([]Message, error) {
|
||||
if !s.Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
@ -122,7 +123,9 @@ func (s *Service) ListForDomain(ctx context.Context, mailboxes []string, domain
|
||||
if domain == "" {
|
||||
return nil, nil
|
||||
}
|
||||
perBox := 25
|
||||
if maxPerBox <= 0 {
|
||||
maxPerBox = 1 << 30 // effectively unbounded (whole history)
|
||||
}
|
||||
q := fmt.Sprintf("(from:%s OR to:%s OR cc:%s) -in:chats", domain, domain, domain)
|
||||
seen := map[string]bool{}
|
||||
var all []Message
|
||||
@ -132,65 +135,77 @@ func (s *Service) ListForDomain(ctx context.Context, mailboxes []string, domain
|
||||
if box == "" {
|
||||
continue
|
||||
}
|
||||
ids, err := s.listIDs(ctx, box, q, perBox)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, id := range ids {
|
||||
m, err := s.getMeta(ctx, box, id)
|
||||
pageToken := ""
|
||||
fetched := 0
|
||||
for {
|
||||
ids, next, err := s.listPage(ctx, box, q, pageToken)
|
||||
if err != nil {
|
||||
continue
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
break
|
||||
}
|
||||
key := m.ID
|
||||
if key == "" {
|
||||
key = box + "/" + id
|
||||
for _, id := range ids {
|
||||
if fetched >= maxPerBox {
|
||||
break
|
||||
}
|
||||
m, err := s.getMeta(ctx, box, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
key := m.ID
|
||||
if key == "" {
|
||||
key = box + "/" + id
|
||||
}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
all = append(all, m)
|
||||
}
|
||||
fetched++
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
if next == "" || fetched >= maxPerBox {
|
||||
break
|
||||
}
|
||||
seen[key] = true
|
||||
all = append(all, m)
|
||||
pageToken = next
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].TS > all[j].TS })
|
||||
if max > 0 && len(all) > max {
|
||||
all = all[:max]
|
||||
}
|
||||
return all, firstErr
|
||||
}
|
||||
|
||||
func (s *Service) listIDs(ctx context.Context, subject, q string, max int) ([]string, error) {
|
||||
func (s *Service) listPage(ctx context.Context, subject, q, pageToken string) (ids []string, next string, err error) {
|
||||
tok, err := s.accessToken(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
u := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=100&q=%s", url.QueryEscape(q))
|
||||
if pageToken != "" {
|
||||
u += "&pageToken=" + url.QueryEscape(pageToken)
|
||||
}
|
||||
u := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=%d&q=%s", max, url.QueryEscape(q))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("gmail list %d for %s", resp.StatusCode, subject)
|
||||
return nil, "", fmt.Errorf("gmail list %d for %s", resp.StatusCode, subject)
|
||||
}
|
||||
var out struct {
|
||||
Messages []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
ids := make([]string, 0, len(out.Messages))
|
||||
ids = make([]string, 0, len(out.Messages))
|
||||
for _, m := range out.Messages {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
return ids, nil
|
||||
return ids, out.NextPageToken, nil
|
||||
}
|
||||
|
||||
func (s *Service) getMeta(ctx context.Context, subject, id string) (Message, error) {
|
||||
|
||||
@ -28,7 +28,8 @@ func All() []interface{} {
|
||||
&Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{},
|
||||
// slice 3 — projects
|
||||
&Company{}, &Product{}, &Version{}, &Project{}, &ProjectMember{},
|
||||
&ClientContact{}, &ProjectTask{}, &TaskComment{}, &MailNote{}, &Contract{}, &ContractFile{}, &PaymentSplit{},
|
||||
&ClientContact{}, &ProjectTask{}, &TaskComment{}, &MailNote{}, &ProjectMailMsg{}, &ProjectMailState{},
|
||||
&Contract{}, &ContractFile{}, &PaymentSplit{},
|
||||
// slice 4 — incentive
|
||||
&IncentiveConfig{}, &PaymentStage{}, &UserIncentive{}, &QuarterlySettlement{},
|
||||
// slice 5 — accounting
|
||||
|
||||
@ -140,6 +140,37 @@ type MailNote struct {
|
||||
|
||||
func (m *MailNote) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// ProjectMailMsg is a stored mail header for a project's client domain (본문 미저장,
|
||||
// 헤더/스니펫만). 동기화 시 (project, Message-ID)로 upsert. Hidden=프로젝트에서 숨김.
|
||||
type ProjectMailMsg struct {
|
||||
Base
|
||||
ProjectID string `gorm:"index:idx_pmm,unique" json:"projectId"`
|
||||
MessageID string `gorm:"index:idx_pmm,unique" json:"messageId"`
|
||||
ThreadID string `json:"threadId"`
|
||||
FromAddr string `json:"from"`
|
||||
ToAddr string `json:"to"`
|
||||
CcAddr string `json:"cc"`
|
||||
Subject string `json:"subject"`
|
||||
Snippet string `json:"snippet"`
|
||||
DateHdr string `json:"date"`
|
||||
Mailbox string `json:"mailbox"`
|
||||
TS int64 `gorm:"index" json:"ts"`
|
||||
Hidden bool `json:"hidden"`
|
||||
HiddenBy string `json:"hiddenBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (m *ProjectMailMsg) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil }
|
||||
|
||||
// ProjectMailState tracks the last mail sync per project.
|
||||
type ProjectMailState struct {
|
||||
ProjectID string `gorm:"primaryKey" json:"projectId"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt"`
|
||||
LastError string `json:"lastError"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Contract holds the [admin-only] commercial terms of a project. BE is the
|
||||
// break-even floor (손해가 안 나는 최소 금액). Exposed ONLY to admins.
|
||||
type Contract struct {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user