feat: 대시보드 내 이슈(/my/tasks) + 전체 캘린더(CalendarEvent) + 메일 AI 요약(OpenAI)
All checks were successful
build-and-push / build (push) Successful in 32s
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:
parent
d9ab9934c0
commit
995dd36167
@ -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{
|
||||
|
||||
75
internal/ai/ai.go
Normal file
75
internal/ai/ai.go
Normal 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
|
||||
}
|
||||
@ -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"),
|
||||
}
|
||||
|
||||
@ -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: "나의 업무"},
|
||||
|
||||
65
internal/httpapi/handlers_calendar.go
Normal file
65
internal/httpapi/handlers_calendar.go
Normal 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})
|
||||
}
|
||||
@ -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})
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user