feat: 대시보드 내 이슈(/my/tasks) + 전체 캘린더(CalendarEvent) + 메일 AI 요약(OpenAI)
All checks were successful
build-and-push / build (push) Successful in 32s

- 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) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 16:06:55 +09:00
parent d9ab9934c0
commit 995dd36167
10 changed files with 215 additions and 8 deletions

View File

@ -49,7 +49,7 @@ func main() {
pusher := push.New(cfg.FCMCredentialsFile) pusher := push.New(cfg.FCMCredentialsFile)
mailer := mailsync.New(cfg.GoogleSACredentialsFile) mailer := mailsync.New(cfg.GoogleSACredentialsFile)
router := httpapi.NewRouter(gdb, store, cfg, pusher, mailer) 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 addr := ":" + cfg.Port
srv := &http.Server{ srv := &http.Server{

75
internal/ai/ai.go Normal file
View File

@ -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
}

View File

@ -35,6 +35,8 @@ type Config struct {
// MailSyncInterval is how often the background mail sync runs (MAIL_SYNC_INTERVAL, // MailSyncInterval is how often the background mail sync runs (MAIL_SYNC_INTERVAL,
// e.g. "15m"). 0 disables periodic sync (on-demand backfill still works). // e.g. "15m"). 0 disables periodic sync (on-demand backfill still works).
MailSyncInterval time.Duration 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): // LogoutURL is the common SSO logout path injected by infra (LOGOUT_URL):
// ends the oauth2-proxy session then redirects to Keycloak end-session. // ends the oauth2-proxy session then redirects to Keycloak end-session.
// The static frontend reads it via /me. Defaults to the infra-common value. // 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", ""), FCMCredentialsFile: env("FCM_CREDENTIALS_FILE", ""),
GoogleSACredentialsFile: env("GOOGLE_SA_CREDENTIALS_FILE", ""), GoogleSACredentialsFile: env("GOOGLE_SA_CREDENTIALS_FILE", ""),
MailSyncInterval: parseDur(env("MAIL_SYNC_INTERVAL", "15m")), MailSyncInterval: parseDur(env("MAIL_SYNC_INTERVAL", "15m")),
OpenAIKey: env("OPENAI_API_KEY", ""),
LogoutURL: env("LOGOUT_URL", LogoutURL: env("LOGOUT_URL",
"/oauth2/sign_out?rd=https%3A%2F%2Fauth.special-partners.com%2Frealms%2Fsp%2Fprotocol%2Fopenid-connect%2Flogout"), "/oauth2/sign_out?rd=https%3A%2F%2Fauth.special-partners.com%2Frealms%2Fsp%2Fprotocol%2Fopenid-connect%2Flogout"),
} }

View File

@ -37,7 +37,8 @@ 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: "inbox", Label: "쪽지함", Path: "/inbox", Icon: "Inbox", Section: "개요"},
{Key: "calendar", Label: "캘린더", Path: "/calendar", Icon: "CalendarDays", 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: "나의 업무"},

View File

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

View File

@ -456,6 +456,30 @@ func (s *Server) projectName(id string) string {
return p.Name 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) { func (s *Server) handlePatchTask(w http.ResponseWriter, r *http.Request) {
var t models.ProjectTask var t models.ProjectTask
if err := s.db.First(&t, "id = ?", chi.URLParam(r, "tId")).Error; err != nil { 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. // integration is configured). Already-stored mail is shown regardless.
if !synced && s.mail.Enabled() { if !synced && s.mail.Enabled() {
resp["syncing"] = true 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 var rows []models.ProjectMailMsg
@ -735,7 +759,7 @@ func (s *Server) handleSyncProjectMail(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "권한이 없습니다") writeError(w, http.StatusForbidden, "권한이 없습니다")
return 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}) writeJSON(w, http.StatusAccepted, map[string]bool{"started": true})
} }

View File

@ -7,6 +7,7 @@ import (
"sync" "sync"
"time" "time"
"spin/internal/ai"
"spin/internal/mailsync" "spin/internal/mailsync"
"spin/internal/models" "spin/internal/models"
@ -26,7 +27,7 @@ func isSyncing(projectID string) bool {
// mailboxes (domain-wide delegation), upserts the headers into ProjectMailMsg, and // mailboxes (domain-wide delegation), upserts the headers into ProjectMailMsg, and
// records sync state. full=true pages the entire history; otherwise just the newest // records sync state. full=true pages the entire history; otherwise just the newest
// page per mailbox (cheap periodic top-up). // 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 { if _, busy := syncInFlight.LoadOrStore(projectID, struct{}{}); busy {
return nil return nil
} }
@ -62,6 +63,19 @@ func syncProjectMail(ctx context.Context, db *gorm.DB, mailer *mailsync.Service,
FirstOrCreate(&row) 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() now := time.Now()
var cnt int64 var cnt int64
db.Model(&models.ProjectMailMsg{}).Where("project_id = ?", projectID).Count(&cnt) 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. // 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. // 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 { if !mailer.Enabled() || interval <= 0 {
log.Printf("mailsync: periodic sync disabled (enabled=%v interval=%s)", mailer.Enabled(), interval) log.Printf("mailsync: periodic sync disabled (enabled=%v interval=%s)", mailer.Enabled(), interval)
return return
@ -90,7 +104,7 @@ func StartMailSyncLoop(ctx context.Context, db *gorm.DB, mailer *mailsync.Servic
for _, p := range projects { for _, p := range projects {
var st models.ProjectMailState var st models.ProjectMailState
full := db.First(&st, "project_id = ?", p.ID).Error != nil 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) log.Printf("mailsync: project %s sync error: %v", p.ID, err)
} }
} }

View File

@ -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.Post("/projects/{id}/contacts", s.handleUpsertContact)
r.Delete("/contacts/{cId}", s.handleDeleteContact) r.Delete("/contacts/{cId}", s.handleDeleteContact)
r.Patch("/projects/{id}/notes", s.handlePatchProjectNotes) 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.Get("/projects/{id}/tasks", s.handleListTasks)
r.Post("/projects/{id}/tasks", s.handleCreateTask) r.Post("/projects/{id}/tasks", s.handleCreateTask)
r.Patch("/tasks/{tId}", s.handlePatchTask) r.Patch("/tasks/{tId}", s.handlePatchTask)

View File

@ -29,7 +29,7 @@ func All() []interface{} {
// slice 3 — projects // slice 3 — projects
&Company{}, &Product{}, &Version{}, &Project{}, &ProjectMember{}, &Company{}, &Product{}, &Version{}, &Project{}, &ProjectMember{},
&ClientContact{}, &ProjectTask{}, &TaskComment{}, &MailNote{}, &ProjectMailMsg{}, &ProjectMailState{}, &ClientContact{}, &ProjectTask{}, &TaskComment{}, &MailNote{}, &ProjectMailMsg{}, &ProjectMailState{},
&Contract{}, &ContractFile{}, &PaymentSplit{}, &CalendarEvent{}, &Contract{}, &ContractFile{}, &PaymentSplit{},
// slice 4 — incentive // slice 4 — incentive
&IncentiveConfig{}, &PaymentStage{}, &UserIncentive{}, &QuarterlySettlement{}, &IncentiveConfig{}, &PaymentStage{}, &UserIncentive{}, &QuarterlySettlement{},
// slice 5 — accounting // slice 5 — accounting

View File

@ -152,6 +152,7 @@ type ProjectMailMsg struct {
CcAddr string `json:"cc"` CcAddr string `json:"cc"`
Subject string `json:"subject"` Subject string `json:"subject"`
Snippet string `json:"snippet"` Snippet string `json:"snippet"`
Summary string `json:"summary"` // AI 자동 요약(한 줄)
DateHdr string `json:"date"` DateHdr string `json:"date"`
Mailbox string `json:"mailbox"` Mailbox string `json:"mailbox"`
TS int64 `gorm:"index" json:"ts"` TS int64 `gorm:"index" json:"ts"`
@ -171,6 +172,25 @@ type ProjectMailState struct {
Count int `json:"count"` 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 // Contract holds the [admin-only] commercial terms of a project. BE is the
// break-even floor (손해가 안 나는 최소 금액). Exposed ONLY to admins. // break-even floor (손해가 안 나는 최소 금액). Exposed ONLY to admins.
type Contract struct { type Contract struct {