feat: 대시보드 내 이슈(/my/tasks) + 전체 캘린더(CalendarEvent) + 메일 AI 요약(OpenAI)
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
co-authored by Claude Opus 4.8
parent d9ab9934c0
commit 995dd36167
10 changed files with 215 additions and 8 deletions
+2 -1
View File
@@ -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
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})
}
+26 -2
View File
@@ -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})
}
+17 -3
View File
@@ -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)
}
}
+5
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.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)