From 995dd36167a69b1aea18479e6a7ecfdb832548c6 Mon Sep 17 00:00:00 2001 From: theorose49 Date: Tue, 30 Jun 2026 16:06:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=82=B4=20=EC=9D=B4=EC=8A=88(/my/tasks)=20+=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=BA=98=EB=A6=B0=EB=8D=94(CalendarEvent)=20+=20?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20AI=20=EC=9A=94=EC=95=BD(OpenAI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /my/tasks: 전 프로젝트에서 나에게 배정된 작업 + projectName (대시보드 JIRA 보드용) · fix: ORDER BY "end"(예약어) 따옴표 처리 - CalendarEvent 모델 + /calendar/events CRUD(본인 소유), nav에 캘린더 추가 - internal/ai(OpenAI, stdlib): 메일 동기화 시 신규 메일에 한 줄 AI 요약 생성(OPENAI_API_KEY) · ProjectMailMsg.Summary, 회당 40건 상한 - nav inbox 라벨 쪽지함으로 통일 Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/server/main.go | 2 +- internal/ai/ai.go | 75 +++++++++++++++++++++++++++ internal/config/config.go | 3 ++ internal/httpapi/handlers.go | 3 +- internal/httpapi/handlers_calendar.go | 65 +++++++++++++++++++++++ internal/httpapi/handlers_projects.go | 28 +++++++++- internal/httpapi/mail_sync.go | 20 +++++-- internal/httpapi/router.go | 5 ++ internal/models/models.go | 2 +- internal/models/project.go | 20 +++++++ 10 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 internal/ai/ai.go create mode 100644 internal/httpapi/handlers_calendar.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 8124679..b51d732 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -49,7 +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) + httpapi.StartMailSyncLoop(context.Background(), gdb, mailer, cfg.OpenAIKey, cfg.MailSyncInterval) addr := ":" + cfg.Port srv := &http.Server{ diff --git a/internal/ai/ai.go b/internal/ai/ai.go new file mode 100644 index 0000000..bfa708a --- /dev/null +++ b/internal/ai/ai.go @@ -0,0 +1,75 @@ +// Package ai provides a tiny OpenAI chat client (stdlib only) for short mail +// summaries. Disabled (no-op) when no API key is configured. +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type Client struct { + key string + model string + http *http.Client +} + +func New(apiKey string) *Client { + return &Client{key: strings.TrimSpace(apiKey), model: "gpt-4o-mini", http: &http.Client{Timeout: 20 * time.Second}} +} + +func (c *Client) Enabled() bool { return c != nil && c.key != "" } + +// Summarize returns a one-line Korean summary of the text. Best-effort; returns +// an error the caller can ignore (summary stays empty). +func (c *Client) Summarize(ctx context.Context, text string) (string, error) { + if !c.Enabled() { + return "", nil + } + text = strings.TrimSpace(text) + if text == "" { + return "", nil + } + if len(text) > 4000 { + text = text[:4000] + } + body := map[string]any{ + "model": c.model, + "messages": []map[string]string{ + {"role": "system", "content": "너는 이메일을 아주 짧게 요약하는 비서다. 한국어로 핵심만 한 문장(최대 40자)으로 답하라. 군더더기·인사말 제외."}, + {"role": "user", "content": text}, + }, + "max_tokens": 80, + "temperature": 0.2, + } + b, _ := json.Marshal(body) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader(b)) + req.Header.Set("Authorization", "Bearer "+c.key) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return "", fmt.Errorf("openai %d", resp.StatusCode) + } + var out struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + if len(out.Choices) == 0 { + return "", nil + } + return strings.TrimSpace(out.Choices[0].Message.Content), nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 05c788c..b14cf5a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,8 @@ type Config struct { // 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 + // OpenAIKey enables short AI mail summaries (OPENAI_API_KEY). Empty = disabled. + OpenAIKey string // 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. @@ -97,6 +99,7 @@ func Load() Config { FCMCredentialsFile: env("FCM_CREDENTIALS_FILE", ""), GoogleSACredentialsFile: env("GOOGLE_SA_CREDENTIALS_FILE", ""), MailSyncInterval: parseDur(env("MAIL_SYNC_INTERVAL", "15m")), + OpenAIKey: env("OPENAI_API_KEY", ""), LogoutURL: env("LOGOUT_URL", "/oauth2/sign_out?rd=https%3A%2F%2Fauth.special-partners.com%2Frealms%2Fsp%2Fprotocol%2Fopenid-connect%2Flogout"), } diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 8609465..8b51c24 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -37,7 +37,8 @@ type NavItem struct { var navItems = []NavItem{ {Key: "dashboard", Label: "대시보드", Path: "/", Icon: "LayoutDashboard", Section: "개요"}, - {Key: "inbox", Label: "메일함", Path: "/inbox", Icon: "Inbox", Section: "개요"}, + {Key: "inbox", Label: "쪽지함", Path: "/inbox", Icon: "Inbox", Section: "개요"}, + {Key: "calendar", Label: "캘린더", Path: "/calendar", Icon: "CalendarDays", 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: "나의 업무"}, diff --git a/internal/httpapi/handlers_calendar.go b/internal/httpapi/handlers_calendar.go new file mode 100644 index 0000000..2fc28ce --- /dev/null +++ b/internal/httpapi/handlers_calendar.go @@ -0,0 +1,65 @@ +package httpapi + +import ( + "net/http" + + "spin/internal/models" + + "github.com/go-chi/chi/v5" +) + +// 개인 캘린더 — 각자 본인 이벤트만 보고 관리(분류: 프로젝트/기타/개인 + 색). + +func (s *Server) handleListEvents(w http.ResponseWriter, r *http.Request) { + var out []models.CalendarEvent + s.db.Where("lower(owner_email) = lower(?)", s.email(r)).Order("start asc").Find(&out) + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) { + var e models.CalendarEvent + if err := decodeJSON(r, &e); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + e.ID = "" + e.OwnerEmail = s.email(r) + s.db.Create(&e) + writeJSON(w, http.StatusCreated, e) +} + +func (s *Server) handlePatchEvent(w http.ResponseWriter, r *http.Request) { + var e models.CalendarEvent + if err := s.db.First(&e, "id = ?", chi.URLParam(r, "eId")).Error; err != nil { + writeError(w, http.StatusNotFound, "일정을 찾을 수 없습니다") + return + } + if !s.owns(r, e.OwnerEmail) { + writeError(w, http.StatusForbidden, "본인 일정만 수정할 수 있습니다") + return + } + var patch map[string]interface{} + if err := decodeJSON(r, &patch); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + delete(patch, "id") + delete(patch, "ownerEmail") + s.db.Model(&e).Updates(snakeKeys(patch)) + s.db.First(&e, "id = ?", e.ID) + writeJSON(w, http.StatusOK, e) +} + +func (s *Server) handleDeleteEvent(w http.ResponseWriter, r *http.Request) { + var e models.CalendarEvent + if err := s.db.First(&e, "id = ?", chi.URLParam(r, "eId")).Error; err != nil { + writeError(w, http.StatusNotFound, "일정을 찾을 수 없습니다") + return + } + if !s.owns(r, e.OwnerEmail) { + writeError(w, http.StatusForbidden, "본인 일정만 삭제할 수 있습니다") + return + } + s.db.Delete(&models.CalendarEvent{}, "id = ?", e.ID) + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/internal/httpapi/handlers_projects.go b/internal/httpapi/handlers_projects.go index 5c93cbf..97ca7e2 100644 --- a/internal/httpapi/handlers_projects.go +++ b/internal/httpapi/handlers_projects.go @@ -456,6 +456,30 @@ func (s *Server) projectName(id string) string { return p.Name } +// handleMyTasks returns every task assigned to the requester across all projects +// (대시보드 '내 이슈' JIRA 보드용), enriched with the project name. +func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) { + me := s.email(r) + var tasks []models.ProjectTask + // "end"는 Postgres 예약어 → 따옴표 필요. + s.db.Where("lower(assignee) = lower(?)", me).Order(`"end" asc, created_at desc`).Find(&tasks) + names := map[string]string{} + var ps []models.Project + s.db.Select("id", "name").Find(&ps) + for _, p := range ps { + names[p.ID] = p.Name + } + type item struct { + models.ProjectTask + ProjectName string `json:"projectName"` + } + out := make([]item, 0, len(tasks)) + for _, t := range tasks { + out = append(out, item{ProjectTask: t, ProjectName: names[t.ProjectID]}) + } + writeJSON(w, http.StatusOK, out) +} + func (s *Server) handlePatchTask(w http.ResponseWriter, r *http.Request) { var t models.ProjectTask if err := s.db.First(&t, "id = ?", chi.URLParam(r, "tId")).Error; err != nil { @@ -614,7 +638,7 @@ func (s *Server) handleListProjectMails(w http.ResponseWriter, r *http.Request) // 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) + go syncProjectMail(context.Background(), s.db, s.mail, s.cfg.OpenAIKey, id, true) } var rows []models.ProjectMailMsg @@ -735,7 +759,7 @@ func (s *Server) handleSyncProjectMail(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "권한이 없습니다") return } - go syncProjectMail(context.Background(), s.db, s.mail, id, true) + go syncProjectMail(context.Background(), s.db, s.mail, s.cfg.OpenAIKey, id, true) writeJSON(w, http.StatusAccepted, map[string]bool{"started": true}) } diff --git a/internal/httpapi/mail_sync.go b/internal/httpapi/mail_sync.go index 8d2e3f9..e36ba10 100644 --- a/internal/httpapi/mail_sync.go +++ b/internal/httpapi/mail_sync.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "spin/internal/ai" "spin/internal/mailsync" "spin/internal/models" @@ -26,7 +27,7 @@ func isSyncing(projectID string) bool { // 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 { +func syncProjectMail(ctx context.Context, db *gorm.DB, mailer *mailsync.Service, aiKey, projectID string, full bool) error { if _, busy := syncInFlight.LoadOrStore(projectID, struct{}{}); busy { return nil } @@ -62,6 +63,19 @@ func syncProjectMail(ctx context.Context, db *gorm.DB, mailer *mailsync.Service, FirstOrCreate(&row) } + // AI 자동 요약: 아직 요약이 없는 메일에만 생성(긁어올 때마다 신규분에 채움). 비용·시간 + // 폭주 방지로 회당 상한. + if cl := ai.New(aiKey); cl.Enabled() { + var need []models.ProjectMailMsg + db.Where("project_id = ? AND summary = '' AND snippet <> ''", projectID).Limit(40).Find(&need) + for _, row := range need { + sum, err := cl.Summarize(ctx, row.Subject+"\n"+row.Snippet) + if err == nil && sum != "" { + db.Model(&models.ProjectMailMsg{}).Where("id = ?", row.ID).Update("summary", sum) + } + } + } + now := time.Now() var cnt int64 db.Model(&models.ProjectMailMsg{}).Where("project_id = ?", projectID).Count(&cnt) @@ -75,7 +89,7 @@ func syncProjectMail(ctx context.Context, db *gorm.DB, mailer *mailsync.Service, // 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) { +func StartMailSyncLoop(ctx context.Context, db *gorm.DB, mailer *mailsync.Service, aiKey string, interval time.Duration) { if !mailer.Enabled() || interval <= 0 { log.Printf("mailsync: periodic sync disabled (enabled=%v interval=%s)", mailer.Enabled(), interval) return @@ -90,7 +104,7 @@ func StartMailSyncLoop(ctx context.Context, db *gorm.DB, mailer *mailsync.Servic 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 { + if err := syncProjectMail(ctx, db, mailer, aiKey, p.ID, full); err != nil { log.Printf("mailsync: project %s sync error: %v", p.ID, err) } } diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 2d003df..9c54888 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -123,6 +123,11 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *p r.Post("/projects/{id}/contacts", s.handleUpsertContact) r.Delete("/contacts/{cId}", s.handleDeleteContact) r.Patch("/projects/{id}/notes", s.handlePatchProjectNotes) + r.Get("/my/tasks", s.handleMyTasks) + r.Get("/calendar/events", s.handleListEvents) + r.Post("/calendar/events", s.handleCreateEvent) + r.Patch("/calendar/events/{eId}", s.handlePatchEvent) + r.Delete("/calendar/events/{eId}", s.handleDeleteEvent) r.Get("/projects/{id}/tasks", s.handleListTasks) r.Post("/projects/{id}/tasks", s.handleCreateTask) r.Patch("/tasks/{tId}", s.handlePatchTask) diff --git a/internal/models/models.go b/internal/models/models.go index 744c001..1e7fcc3 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -29,7 +29,7 @@ func All() []interface{} { // slice 3 — projects &Company{}, &Product{}, &Version{}, &Project{}, &ProjectMember{}, &ClientContact{}, &ProjectTask{}, &TaskComment{}, &MailNote{}, &ProjectMailMsg{}, &ProjectMailState{}, - &Contract{}, &ContractFile{}, &PaymentSplit{}, + &CalendarEvent{}, &Contract{}, &ContractFile{}, &PaymentSplit{}, // slice 4 — incentive &IncentiveConfig{}, &PaymentStage{}, &UserIncentive{}, &QuarterlySettlement{}, // slice 5 — accounting diff --git a/internal/models/project.go b/internal/models/project.go index b69fefa..19b8701 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -152,6 +152,7 @@ type ProjectMailMsg struct { CcAddr string `json:"cc"` Subject string `json:"subject"` Snippet string `json:"snippet"` + Summary string `json:"summary"` // AI 자동 요약(한 줄) DateHdr string `json:"date"` Mailbox string `json:"mailbox"` TS int64 `gorm:"index" json:"ts"` @@ -171,6 +172,25 @@ type ProjectMailState struct { Count int `json:"count"` } +// CalendarEvent is a personal calendar entry tagged by category(프로젝트/기타/개인) +// with a color. Owned by one member. +type CalendarEvent struct { + Base + OwnerEmail string `gorm:"index" json:"ownerEmail"` + Title string `json:"title"` + Category string `json:"category"` // project | etc | personal (분류 키) + ProjectID string `json:"projectId"` // category=project일 때 연결 + Color string `json:"color"` // 표시 색 + Start string `json:"start"` // YYYY-MM-DD + End string `json:"end"` // YYYY-MM-DD (빈값=하루) + AllDay bool `json:"allDay"` + Memo string `json:"memo"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (m *CalendarEvent) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil } + // Contract holds the [admin-only] commercial terms of a project. BE is the // break-even floor (손해가 안 나는 최소 금액). Exposed ONLY to admins. type Contract struct {