feat: 대시보드 내 이슈(/my/tasks) + 전체 캘린더(CalendarEvent) + 메일 AI 요약(OpenAI)
build-and-push / build (push) Successful in 32s
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:
co-authored by
Claude Opus 4.8
parent
d9ab9934c0
commit
995dd36167
@@ -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: "나의 업무"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user