mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-04 04:23:00 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultActivityLimit = 8
|
||||
maxActivityLimit = 40
|
||||
)
|
||||
|
||||
var activityTypes = map[string]struct{}{
|
||||
"task": {},
|
||||
"board": {},
|
||||
"calendar": {},
|
||||
"note": {},
|
||||
"focus": {},
|
||||
"mail": {},
|
||||
"invite": {},
|
||||
"system": {},
|
||||
}
|
||||
|
||||
type activityListParams struct {
|
||||
Limit int
|
||||
Type string
|
||||
Query string
|
||||
}
|
||||
|
||||
func parseActivityListParams(limitRaw string, typeRaw string, queryRaw string) (activityListParams, error) {
|
||||
params := activityListParams{
|
||||
Limit: defaultActivityLimit,
|
||||
Type: strings.TrimSpace(strings.ToLower(typeRaw)),
|
||||
Query: strings.TrimSpace(strings.ToLower(queryRaw)),
|
||||
}
|
||||
|
||||
if strings.TrimSpace(limitRaw) != "" {
|
||||
parsed, err := strconv.Atoi(limitRaw)
|
||||
if err != nil {
|
||||
return activityListParams{}, fmt.Errorf("invalid limit: expected integer between 1 and %d", maxActivityLimit)
|
||||
}
|
||||
if parsed < 1 || parsed > maxActivityLimit {
|
||||
return activityListParams{}, fmt.Errorf("invalid limit: expected integer between 1 and %d", maxActivityLimit)
|
||||
}
|
||||
params.Limit = parsed
|
||||
}
|
||||
|
||||
if params.Type != "" {
|
||||
if _, ok := activityTypes[params.Type]; !ok {
|
||||
return activityListParams{}, fmt.Errorf("invalid activity type")
|
||||
}
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func filterActivityEntries(entries []store.ActivityEntry, params activityListParams) []store.ActivityEntry {
|
||||
if len(entries) == 0 || params.Limit == 0 {
|
||||
return []store.ActivityEntry{}
|
||||
}
|
||||
|
||||
filtered := make([]store.ActivityEntry, 0, params.Limit)
|
||||
for _, entry := range entries {
|
||||
if params.Type != "" && classifyActivityEntry(entry) != params.Type {
|
||||
continue
|
||||
}
|
||||
if params.Query != "" {
|
||||
title := strings.ToLower(entry.Title)
|
||||
detail := strings.ToLower(entry.Detail)
|
||||
if !strings.Contains(title, params.Query) && !strings.Contains(detail, params.Query) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, entry)
|
||||
if len(filtered) == params.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func classifyActivityEntry(entry store.ActivityEntry) string {
|
||||
text := strings.ToLower(strings.TrimSpace(entry.Title + " " + entry.Detail))
|
||||
|
||||
switch {
|
||||
case strings.Contains(text, "invite"):
|
||||
return "invite"
|
||||
case strings.Contains(text, "board"):
|
||||
return "board"
|
||||
case strings.Contains(text, "mail"), strings.Contains(text, "inbox"), strings.Contains(text, "smtp"), strings.Contains(text, "imap"):
|
||||
return "mail"
|
||||
case strings.Contains(text, "calendar"), strings.Contains(text, "event"):
|
||||
return "calendar"
|
||||
case strings.Contains(text, "note"):
|
||||
return "note"
|
||||
case strings.Contains(text, "focus"), strings.Contains(text, "pomodoro"):
|
||||
return "focus"
|
||||
case strings.Contains(text, "task"):
|
||||
return "task"
|
||||
default:
|
||||
return "system"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestParseActivityListParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params, err := parseActivityListParams("", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if params.Limit != defaultActivityLimit {
|
||||
t.Fatalf("default limit = %d, want %d", params.Limit, defaultActivityLimit)
|
||||
}
|
||||
|
||||
params, err = parseActivityListParams("12", "task", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if params.Limit != 12 || params.Type != "task" || params.Query != "foo" {
|
||||
t.Fatalf("unexpected parsed params: %+v", params)
|
||||
}
|
||||
|
||||
if _, err := parseActivityListParams("0", "", ""); err == nil {
|
||||
t.Fatal("expected error for out-of-range limit")
|
||||
}
|
||||
if _, err := parseActivityListParams("abc", "", ""); err == nil {
|
||||
t.Fatal("expected error for non-numeric limit")
|
||||
}
|
||||
if _, err := parseActivityListParams("5", "unknown", ""); err == nil {
|
||||
t.Fatal("expected error for invalid activity type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterActivityEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entries := []store.ActivityEntry{
|
||||
{ID: "1", Title: "Task created", Detail: "Write docs", CreatedAt: time.Now().Add(-1 * time.Hour)},
|
||||
{ID: "2", Title: "Invite accepted", Detail: "alex@example.com joined", CreatedAt: time.Now().Add(-2 * time.Hour)},
|
||||
{ID: "3", Title: "Mail synced", Detail: "Inbox updated", CreatedAt: time.Now().Add(-3 * time.Hour)},
|
||||
}
|
||||
|
||||
filtered := filterActivityEntries(entries, activityListParams{
|
||||
Limit: 5,
|
||||
Type: "invite",
|
||||
})
|
||||
if len(filtered) != 1 || filtered[0].ID != "2" {
|
||||
t.Fatalf("expected invite entry only, got %+v", filtered)
|
||||
}
|
||||
|
||||
filtered = filterActivityEntries(entries, activityListParams{
|
||||
Limit: 5,
|
||||
Query: "docs",
|
||||
})
|
||||
if len(filtered) != 1 || filtered[0].ID != "1" {
|
||||
t.Fatalf("expected docs match only, got %+v", filtered)
|
||||
}
|
||||
|
||||
filtered = filterActivityEntries(entries, activityListParams{
|
||||
Limit: 2,
|
||||
})
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("expected two entries due to limit, got %d", len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyActivityEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
entry store.ActivityEntry
|
||||
want string
|
||||
}{
|
||||
{entry: store.ActivityEntry{Title: "Task updated", Detail: "Done"}, want: "task"},
|
||||
{entry: store.ActivityEntry{Title: "Board group added", Detail: "Inbox"}, want: "board"},
|
||||
{entry: store.ActivityEntry{Title: "Event moved", Detail: "Calendar item"}, want: "calendar"},
|
||||
{entry: store.ActivityEntry{Title: "Note saved", Detail: "Draft"}, want: "note"},
|
||||
{entry: store.ActivityEntry{Title: "Focus started", Detail: "Pomodoro"}, want: "focus"},
|
||||
{entry: store.ActivityEntry{Title: "Mail sent", Detail: "SMTP ok"}, want: "mail"},
|
||||
{entry: store.ActivityEntry{Title: "Invite revoked", Detail: "guest"}, want: "invite"},
|
||||
{entry: store.ActivityEntry{Title: "Workspace synced", Detail: "ok"}, want: "system"},
|
||||
}
|
||||
|
||||
for _, test := range cases {
|
||||
got := classifyActivityEntry(test.entry)
|
||||
if got != test.want {
|
||||
t.Fatalf("classifyActivityEntry(%q) = %q, want %q", test.entry.Title, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerCRMRoutes(group *gin.RouterGroup) {
|
||||
// Contacts
|
||||
group.GET("/contacts", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListContacts(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.GET("/contacts/:contactId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": contact})
|
||||
})
|
||||
|
||||
group.POST("/contacts", func(c *gin.Context) {
|
||||
var input store.CreateContactInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
contact := s.store.CreateContact(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": contact})
|
||||
})
|
||||
|
||||
group.PATCH("/contacts/:contactId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateContactInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateContact(c.Param("contactId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
group.DELETE("/contacts/:contactId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.DeleteContact(c.Param("contactId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Companies
|
||||
group.GET("/companies", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListCompanies(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.GET("/companies/:companyId", func(c *gin.Context) {
|
||||
company, err := s.store.GetCompanyByID(c.Param("companyId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": company})
|
||||
})
|
||||
|
||||
group.POST("/companies", func(c *gin.Context) {
|
||||
var input store.CreateCompanyInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
company := s.store.CreateCompany(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": company})
|
||||
})
|
||||
|
||||
group.PATCH("/companies/:companyId", func(c *gin.Context) {
|
||||
company, err := s.store.GetCompanyByID(c.Param("companyId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateCompanyInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateCompany(c.Param("companyId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
group.DELETE("/companies/:companyId", func(c *gin.Context) {
|
||||
company, err := s.store.GetCompanyByID(c.Param("companyId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.DeleteCompany(c.Param("companyId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Contact-Task linking
|
||||
group.POST("/contacts/:contactId/tasks/:taskId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.LinkContactToTask(c.Param("contactId"), c.Param("taskId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
group.DELETE("/contacts/:contactId/tasks/:taskId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.UnlinkContactFromTask(c.Param("contactId"), c.Param("taskId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Contact-Event linking
|
||||
group.POST("/contacts/:contactId/events/:eventId", func(c *gin.Context) {
|
||||
contact, err := s.store.GetContactByID(c.Param("contactId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.LinkContactToEvent(c.Param("contactId"), c.Param("eventId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const requestIDContextKey = "requestID"
|
||||
|
||||
func requestIDMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := strings.TrimSpace(c.GetHeader("X-Request-Id"))
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
}
|
||||
c.Set(requestIDContextKey, requestID)
|
||||
c.Header("X-Request-Id", requestID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func requestIDFromContext(c *gin.Context) string {
|
||||
if value, exists := c.Get(requestIDContextKey); exists {
|
||||
if requestID, ok := value.(string); ok {
|
||||
return requestID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) writeStatusError(c *gin.Context, status int, message string) {
|
||||
code := "internal_error"
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
code = "bad_request"
|
||||
case http.StatusUnauthorized:
|
||||
code = "unauthorized"
|
||||
case http.StatusForbidden:
|
||||
code = "forbidden"
|
||||
case http.StatusNotFound:
|
||||
code = "not_found"
|
||||
case http.StatusConflict:
|
||||
code = "conflict"
|
||||
case http.StatusBadGateway:
|
||||
code = "upstream_error"
|
||||
case http.StatusServiceUnavailable:
|
||||
code = "service_unavailable"
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = http.StatusText(status)
|
||||
}
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"code": code,
|
||||
"message": message,
|
||||
"requestId": requestIDFromContext(c),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerIntegrationRoutes(group *gin.RouterGroup) {
|
||||
// Integrations
|
||||
group.GET("/integrations", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListIntegrations(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.POST("/integrations", func(c *gin.Context) {
|
||||
var input store.CreateIntegrationInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
integration := s.store.CreateIntegration(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": integration})
|
||||
})
|
||||
|
||||
group.DELETE("/integrations/:integrationId", func(c *gin.Context) {
|
||||
integration, err := s.store.GetIntegrationByID(c.Param("integrationId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, integration.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteIntegration(integration.ID); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Webhooks
|
||||
group.GET("/webhooks", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListWebhooks(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.POST("/webhooks", func(c *gin.Context) {
|
||||
var input store.CreateWebhookInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
webhook := s.store.CreateWebhook(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": webhook})
|
||||
})
|
||||
|
||||
group.DELETE("/webhooks/:webhookId", func(c *gin.Context) {
|
||||
if err := s.store.DeleteWebhook(c.Param("webhookId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Notifications
|
||||
group.GET("/notifications", func(c *gin.Context) {
|
||||
user := s.sessionUser(c)
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListNotifications(user.Email, limit)})
|
||||
})
|
||||
|
||||
group.POST("/notifications/:notificationId/read", func(c *gin.Context) {
|
||||
if err := s.store.MarkNotificationRead(c.Param("notificationId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
group.POST("/notifications/read-all", func(c *gin.Context) {
|
||||
user := s.sessionUser(c)
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
if err := s.store.MarkAllNotificationsRead(user.Email); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
group.GET("/notifications/unread-count", func(c *gin.Context) {
|
||||
user := s.sessionUser(c)
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"count": s.store.UnreadNotificationCount(user.Email)})
|
||||
})
|
||||
|
||||
// Presence
|
||||
group.POST("/presence", func(c *gin.Context) {
|
||||
var input store.UpdatePresenceInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
presence := s.store.UpdatePresence(input)
|
||||
c.JSON(http.StatusOK, gin.H{"data": presence})
|
||||
})
|
||||
|
||||
// Create notification (internal use)
|
||||
group.POST("/notifications", func(c *gin.Context) {
|
||||
var input store.CreateNotificationInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
notification := s.store.CreateNotification(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": notification})
|
||||
})
|
||||
|
||||
group.GET("/presence", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
entityType := c.Query("entityType")
|
||||
entityID := c.Query("entityId")
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListPresence(workspaceSlug, entityType, entityID)})
|
||||
})
|
||||
|
||||
group.DELETE("/presence", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
user := s.sessionUser(c)
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
if err := s.store.ClearPresence(workspaceSlug, user.Email); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func requestLogMiddleware(logger *zap.Logger) gin.HandlerFunc {
|
||||
baseLogger := logger
|
||||
if baseLogger == nil {
|
||||
baseLogger = zap.NewNop()
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
startedAt := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
c.Next()
|
||||
|
||||
status := c.Writer.Status()
|
||||
latency := time.Since(startedAt)
|
||||
requestID := requestIDFromContext(c)
|
||||
|
||||
if path == "/v1/health" && status < 400 {
|
||||
return
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("requestId", requestID),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", path),
|
||||
zap.Int("status", status),
|
||||
zap.Duration("latency", latency),
|
||||
zap.String("clientIP", c.ClientIP()),
|
||||
zap.String("userAgent", c.Request.UserAgent()),
|
||||
zap.Int("responseBytes", c.Writer.Size()),
|
||||
}
|
||||
if query != "" {
|
||||
fields = append(fields, zap.String("query", query))
|
||||
}
|
||||
if len(c.Errors) > 0 {
|
||||
fields = append(fields, zap.String("errors", c.Errors.String()))
|
||||
}
|
||||
|
||||
switch {
|
||||
case status >= 500:
|
||||
baseLogger.Error("http request completed with server error", fields...)
|
||||
case status >= 400:
|
||||
baseLogger.Warn("http request completed with client error", fields...)
|
||||
default:
|
||||
baseLogger.Info("http request completed", fields...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"productier/apps/backend/internal/mailruntime"
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
type connectMailboxRequest struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
Label string `json:"label"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IMAPHost string `json:"imapHost" binding:"required"`
|
||||
IMAPPort int `json:"imapPort"`
|
||||
IMAPUsername string `json:"imapUsername"`
|
||||
IMAPPassword string `json:"imapPassword" binding:"required"`
|
||||
IMAPUseTLS bool `json:"imapUseTls"`
|
||||
SMTPHost string `json:"smtpHost" binding:"required"`
|
||||
SMTPPort int `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPPassword string `json:"smtpPassword"`
|
||||
SMTPUseTLS bool `json:"smtpUseTls"`
|
||||
}
|
||||
|
||||
type createOutgoingMailRequest struct {
|
||||
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
|
||||
MailboxID string `json:"mailboxId" binding:"required"`
|
||||
To []store.MailAddress `json:"to" binding:"required"`
|
||||
Cc []store.MailAddress `json:"cc"`
|
||||
Bcc []store.MailAddress `json:"bcc"`
|
||||
Subject string `json:"subject"`
|
||||
TextBody string `json:"textBody"`
|
||||
HTMLBody string `json:"htmlBody"`
|
||||
ScheduledFor *time.Time `json:"scheduledFor"`
|
||||
}
|
||||
|
||||
type createTaskFromMailRequest struct {
|
||||
BoardGroupID string `json:"boardGroupId" binding:"required"`
|
||||
Title string `json:"title"`
|
||||
DueAt *time.Time `json:"dueAt"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func (s *Server) registerMailRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/mailboxes", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMailboxes(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.POST("/mailboxes", func(c *gin.Context) {
|
||||
var input connectMailboxRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mailbox, err := s.mail.ConnectMailbox(c.Request.Context(), mailruntime.ConnectMailboxInput{
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
Label: input.Label,
|
||||
Email: input.Email,
|
||||
DisplayName: input.DisplayName,
|
||||
IMAPHost: input.IMAPHost,
|
||||
IMAPPort: input.IMAPPort,
|
||||
IMAPUsername: input.IMAPUsername,
|
||||
IMAPPassword: input.IMAPPassword,
|
||||
IMAPUseTLS: input.IMAPUseTLS,
|
||||
SMTPHost: input.SMTPHost,
|
||||
SMTPPort: input.SMTPPort,
|
||||
SMTPUsername: input.SMTPUsername,
|
||||
SMTPPassword: input.SMTPPassword,
|
||||
SMTPUseTLS: input.SMTPUseTLS,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": mailbox})
|
||||
})
|
||||
|
||||
group.POST("/mailboxes/:mailboxId/sync", func(c *gin.Context) {
|
||||
mailbox, err := s.store.GetMailboxByID(c.Param("mailboxId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, mailbox.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
if err := s.mail.SyncMailbox(c.Request.Context(), mailbox.ID); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
updated, err := s.store.GetMailboxByID(mailbox.ID)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
group.GET("/mail/messages", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMailMessages(workspaceSlug, c.Query("mailboxId"))})
|
||||
})
|
||||
|
||||
group.GET("/mail/outgoing", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListOutgoingMails(workspaceSlug, c.Query("mailboxId"))})
|
||||
})
|
||||
|
||||
group.POST("/mail/outgoing", func(c *gin.Context) {
|
||||
var input createOutgoingMailRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
mailbox, err := s.store.GetMailboxByID(input.MailboxID)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if mailbox.WorkspaceSlug != input.WorkspaceSlug {
|
||||
s.writeStatusError(c, http.StatusForbidden, "mailbox does not belong to workspace")
|
||||
return
|
||||
}
|
||||
|
||||
item, err := s.mail.QueueOutgoingMail(c.Request.Context(), mailruntime.QueueOutgoingMailInput{
|
||||
WorkspaceSlug: input.WorkspaceSlug,
|
||||
MailboxID: input.MailboxID,
|
||||
To: input.To,
|
||||
Cc: input.Cc,
|
||||
Bcc: input.Bcc,
|
||||
Subject: input.Subject,
|
||||
TextBody: input.TextBody,
|
||||
HTMLBody: input.HTMLBody,
|
||||
ScheduledFor: input.ScheduledFor,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
})
|
||||
|
||||
group.POST("/mail/messages/:messageId/create-task", func(c *gin.Context) {
|
||||
message, err := s.store.GetMailMessageByID(c.Param("messageId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, message.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
if message.LinkedTaskID != nil {
|
||||
s.writeStatusError(c, http.StatusConflict, "message already linked to a task")
|
||||
return
|
||||
}
|
||||
|
||||
var input createTaskFromMailRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
description := mailTaskDescription(message)
|
||||
task := s.store.CreateTask(store.CreateTaskInput{
|
||||
WorkspaceSlug: message.WorkspaceSlug,
|
||||
BoardGroupID: input.BoardGroupID,
|
||||
Title: firstNonBlank(strings.TrimSpace(input.Title), strings.TrimSpace(message.Subject), "Follow up on email"),
|
||||
Description: description,
|
||||
DueAt: input.DueAt,
|
||||
Color: withFallback(input.Color, "blue"),
|
||||
})
|
||||
|
||||
if _, err := s.store.LinkMailMessageTask(message.ID, task.ID); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": task})
|
||||
})
|
||||
}
|
||||
|
||||
func mailTaskDescription(message store.MailMessage) string {
|
||||
var builder strings.Builder
|
||||
if message.From.Email != "" {
|
||||
builder.WriteString(fmt.Sprintf("From: %s <%s>\n\n", firstNonBlank(message.From.Name, "Sender"), message.From.Email))
|
||||
}
|
||||
body := firstNonBlank(strings.TrimSpace(message.TextBody), strings.TrimSpace(message.Snippet))
|
||||
builder.WriteString(body)
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func firstNonBlank(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withFallback(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type routeMetricSnapshot struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Status int `json:"status"`
|
||||
Count uint64 `json:"count"`
|
||||
AvgLatencyMs float64 `json:"avgLatencyMs"`
|
||||
MaxLatencyMs float64 `json:"maxLatencyMs"`
|
||||
LastSeenAt string `json:"lastSeenAt"`
|
||||
}
|
||||
|
||||
type metricsSnapshot struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
RequestsTotal uint64 `json:"requestsTotal"`
|
||||
StatusClassTotals map[string]uint64 `json:"statusClassTotals"`
|
||||
Routes []routeMetricSnapshot `json:"routes"`
|
||||
}
|
||||
|
||||
type routeMetricBucket struct {
|
||||
Method string
|
||||
Path string
|
||||
Status int
|
||||
Count uint64
|
||||
TotalLatencyNanos float64
|
||||
MaxLatencyNanos float64
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
type requestMetrics struct {
|
||||
startedAt time.Time
|
||||
requestsTotal uint64
|
||||
status2xxTotal uint64
|
||||
status3xxTotal uint64
|
||||
status4xxTotal uint64
|
||||
status5xxTotal uint64
|
||||
statusOther uint64
|
||||
|
||||
mu sync.RWMutex
|
||||
buckets map[string]*routeMetricBucket
|
||||
}
|
||||
|
||||
func newRequestMetrics() *requestMetrics {
|
||||
return &requestMetrics{
|
||||
startedAt: time.Now().UTC(),
|
||||
buckets: make(map[string]*routeMetricBucket),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *requestMetrics) observe(method, path string, status int, latency time.Duration) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if path == "" {
|
||||
path = "<unmatched>"
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
latencyNanos := float64(latency.Nanoseconds())
|
||||
key := method + " " + path + " " + itoa(status)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.requestsTotal++
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
m.status2xxTotal++
|
||||
case status >= 300 && status < 400:
|
||||
m.status3xxTotal++
|
||||
case status >= 400 && status < 500:
|
||||
m.status4xxTotal++
|
||||
case status >= 500 && status < 600:
|
||||
m.status5xxTotal++
|
||||
default:
|
||||
m.statusOther++
|
||||
}
|
||||
|
||||
bucket, exists := m.buckets[key]
|
||||
if !exists {
|
||||
bucket = &routeMetricBucket{
|
||||
Method: method,
|
||||
Path: path,
|
||||
Status: status,
|
||||
Count: 1,
|
||||
TotalLatencyNanos: latencyNanos,
|
||||
MaxLatencyNanos: latencyNanos,
|
||||
LastSeenAt: now,
|
||||
}
|
||||
m.buckets[key] = bucket
|
||||
return
|
||||
}
|
||||
|
||||
bucket.Count++
|
||||
bucket.TotalLatencyNanos += latencyNanos
|
||||
if latencyNanos > bucket.MaxLatencyNanos {
|
||||
bucket.MaxLatencyNanos = latencyNanos
|
||||
}
|
||||
bucket.LastSeenAt = now
|
||||
}
|
||||
|
||||
func (m *requestMetrics) snapshot() metricsSnapshot {
|
||||
if m == nil {
|
||||
return metricsSnapshot{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
StatusClassTotals: map[string]uint64{},
|
||||
Routes: []routeMetricSnapshot{},
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
routes := make([]routeMetricSnapshot, 0, len(m.buckets))
|
||||
for _, bucket := range m.buckets {
|
||||
avgMs := 0.0
|
||||
if bucket.Count > 0 {
|
||||
avgMs = (bucket.TotalLatencyNanos / float64(bucket.Count)) / float64(time.Millisecond)
|
||||
}
|
||||
routes = append(routes, routeMetricSnapshot{
|
||||
Method: bucket.Method,
|
||||
Path: bucket.Path,
|
||||
Status: bucket.Status,
|
||||
Count: bucket.Count,
|
||||
AvgLatencyMs: avgMs,
|
||||
MaxLatencyMs: bucket.MaxLatencyNanos / float64(time.Millisecond),
|
||||
LastSeenAt: bucket.LastSeenAt.Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
if routes[i].Method != routes[j].Method {
|
||||
return routes[i].Method < routes[j].Method
|
||||
}
|
||||
if routes[i].Path != routes[j].Path {
|
||||
return routes[i].Path < routes[j].Path
|
||||
}
|
||||
return routes[i].Status < routes[j].Status
|
||||
})
|
||||
|
||||
return metricsSnapshot{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
UptimeSeconds: int64(time.Since(m.startedAt).Seconds()),
|
||||
RequestsTotal: m.requestsTotal,
|
||||
StatusClassTotals: map[string]uint64{
|
||||
"2xx": m.status2xxTotal,
|
||||
"3xx": m.status3xxTotal,
|
||||
"4xx": m.status4xxTotal,
|
||||
"5xx": m.status5xxTotal,
|
||||
"other": m.statusOther,
|
||||
},
|
||||
Routes: routes,
|
||||
}
|
||||
}
|
||||
|
||||
func requestMetricsMiddleware(metrics *requestMetrics) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
startedAt := time.Now()
|
||||
c.Next()
|
||||
|
||||
path := c.FullPath()
|
||||
if path == "" {
|
||||
path = c.Request.URL.Path
|
||||
}
|
||||
if path == "/v1/metrics" || path == "/v1/metrics/prometheus" {
|
||||
return
|
||||
}
|
||||
metrics.observe(c.Request.Method, path, c.Writer.Status(), time.Since(startedAt))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *requestMetrics) snapshotPrometheus() string {
|
||||
snapshot := m.snapshot()
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString("# HELP productier_http_uptime_seconds Process uptime in seconds.\n")
|
||||
builder.WriteString("# TYPE productier_http_uptime_seconds gauge\n")
|
||||
builder.WriteString("productier_http_uptime_seconds ")
|
||||
builder.WriteString(strconv.FormatInt(snapshot.UptimeSeconds, 10))
|
||||
builder.WriteByte('\n')
|
||||
|
||||
builder.WriteString("# HELP productier_http_requests_total Total HTTP requests by status class.\n")
|
||||
builder.WriteString("# TYPE productier_http_requests_total counter\n")
|
||||
statusClasses := []string{"2xx", "3xx", "4xx", "5xx", "other"}
|
||||
for _, statusClass := range statusClasses {
|
||||
builder.WriteString(`productier_http_requests_total{status_class="`)
|
||||
builder.WriteString(escapePrometheusLabelValue(statusClass))
|
||||
builder.WriteString(`"} `)
|
||||
builder.WriteString(strconv.FormatUint(snapshot.StatusClassTotals[statusClass], 10))
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
|
||||
builder.WriteString("# HELP productier_http_requests_route_total Total HTTP requests by route and status code.\n")
|
||||
builder.WriteString("# TYPE productier_http_requests_route_total counter\n")
|
||||
builder.WriteString("# HELP productier_http_request_latency_avg_ms Average request latency in milliseconds by route and status code.\n")
|
||||
builder.WriteString("# TYPE productier_http_request_latency_avg_ms gauge\n")
|
||||
builder.WriteString("# HELP productier_http_request_latency_max_ms Max request latency in milliseconds by route and status code.\n")
|
||||
builder.WriteString("# TYPE productier_http_request_latency_max_ms gauge\n")
|
||||
for _, route := range snapshot.Routes {
|
||||
labels := `method="` + escapePrometheusLabelValue(route.Method) +
|
||||
`",path="` + escapePrometheusLabelValue(route.Path) +
|
||||
`",status="` + strconv.Itoa(route.Status) + `"`
|
||||
builder.WriteString("productier_http_requests_route_total{")
|
||||
builder.WriteString(labels)
|
||||
builder.WriteString("} ")
|
||||
builder.WriteString(strconv.FormatUint(route.Count, 10))
|
||||
builder.WriteByte('\n')
|
||||
|
||||
builder.WriteString("productier_http_request_latency_avg_ms{")
|
||||
builder.WriteString(labels)
|
||||
builder.WriteString("} ")
|
||||
builder.WriteString(strconv.FormatFloat(route.AvgLatencyMs, 'f', 3, 64))
|
||||
builder.WriteByte('\n')
|
||||
|
||||
builder.WriteString("productier_http_request_latency_max_ms{")
|
||||
builder.WriteString(labels)
|
||||
builder.WriteString("} ")
|
||||
builder.WriteString(strconv.FormatFloat(route.MaxLatencyMs, 'f', 3, 64))
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func escapePrometheusLabelValue(value string) string {
|
||||
escaped := strings.ReplaceAll(value, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
|
||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||
return escaped
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
isNegative := value < 0
|
||||
if isNegative {
|
||||
value = -value
|
||||
}
|
||||
var digits [20]byte
|
||||
index := len(digits)
|
||||
for value > 0 {
|
||||
index--
|
||||
digits[index] = byte('0' + (value % 10))
|
||||
value /= 10
|
||||
}
|
||||
if isNegative {
|
||||
index--
|
||||
digits[index] = '-'
|
||||
}
|
||||
return string(digits[index:])
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *Server) authorizeMetricsRequest(c *gin.Context) bool {
|
||||
expectedToken := strings.TrimSpace(s.metricsToken)
|
||||
if expectedToken == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
providedToken := strings.TrimSpace(c.GetHeader("X-Metrics-Token"))
|
||||
if providedToken == "" {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
providedToken = strings.TrimSpace(authHeader[len("Bearer "):])
|
||||
}
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "valid metrics token required")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestAuthorizeMetricsRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
createContext := func(headers map[string]string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/metrics", nil)
|
||||
for key, value := range headers {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
context.Request = request
|
||||
return context, recorder
|
||||
}
|
||||
|
||||
t.Run("allows when token unset", func(t *testing.T) {
|
||||
server := &Server{metricsToken: ""}
|
||||
context, _ := createContext(nil)
|
||||
if !server.authorizeMetricsRequest(context) {
|
||||
t.Fatal("expected request to pass when metrics token is unset")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts bearer token", func(t *testing.T) {
|
||||
server := &Server{metricsToken: "strong-metrics-token"}
|
||||
context, _ := createContext(map[string]string{
|
||||
"Authorization": "Bearer strong-metrics-token",
|
||||
})
|
||||
if !server.authorizeMetricsRequest(context) {
|
||||
t.Fatal("expected bearer token to authorize request")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts x metrics token", func(t *testing.T) {
|
||||
server := &Server{metricsToken: "strong-metrics-token"}
|
||||
context, _ := createContext(map[string]string{
|
||||
"X-Metrics-Token": "strong-metrics-token",
|
||||
})
|
||||
if !server.authorizeMetricsRequest(context) {
|
||||
t.Fatal("expected X-Metrics-Token to authorize request")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid token", func(t *testing.T) {
|
||||
server := &Server{metricsToken: "strong-metrics-token"}
|
||||
context, recorder := createContext(map[string]string{
|
||||
"Authorization": "Bearer wrong-token",
|
||||
})
|
||||
if server.authorizeMetricsRequest(context) {
|
||||
t.Fatal("expected invalid token to be rejected")
|
||||
}
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRequestMetricsObserveAndSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metrics := newRequestMetrics()
|
||||
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 100*time.Millisecond)
|
||||
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 200*time.Millisecond)
|
||||
metrics.observe(http.MethodPost, "/v1/tasks", http.StatusCreated, 40*time.Millisecond)
|
||||
|
||||
snapshot := metrics.snapshot()
|
||||
if snapshot.RequestsTotal != 3 {
|
||||
t.Fatalf("requestsTotal = %d, want 3", snapshot.RequestsTotal)
|
||||
}
|
||||
if snapshot.StatusClassTotals["2xx"] != 3 {
|
||||
t.Fatalf("2xx total = %d, want 3", snapshot.StatusClassTotals["2xx"])
|
||||
}
|
||||
if len(snapshot.Routes) != 2 {
|
||||
t.Fatalf("route bucket count = %d, want 2", len(snapshot.Routes))
|
||||
}
|
||||
if snapshot.UptimeSeconds < 0 {
|
||||
t.Fatalf("uptimeSeconds = %d, want >= 0", snapshot.UptimeSeconds)
|
||||
}
|
||||
|
||||
health := findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/health", http.StatusOK)
|
||||
if health == nil {
|
||||
t.Fatal("missing route metric for GET /v1/health 200")
|
||||
}
|
||||
if health.Count != 2 {
|
||||
t.Fatalf("health count = %d, want 2", health.Count)
|
||||
}
|
||||
if health.AvgLatencyMs != 150 {
|
||||
t.Fatalf("health avgLatencyMs = %.2f, want 150", health.AvgLatencyMs)
|
||||
}
|
||||
if health.MaxLatencyMs != 200 {
|
||||
t.Fatalf("health maxLatencyMs = %.2f, want 200", health.MaxLatencyMs)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, health.LastSeenAt); err != nil {
|
||||
t.Fatalf("health lastSeenAt parse error: %v", err)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, snapshot.GeneratedAt); err != nil {
|
||||
t.Fatalf("snapshot generatedAt parse error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestMetricsMiddlewareSkipsMetricsEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
metrics := newRequestMetrics()
|
||||
router := gin.New()
|
||||
router.Use(requestMetricsMiddleware(metrics))
|
||||
router.GET("/v1/health", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
router.GET("/v1/metrics", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
router.GET("/v1/metrics/prometheus", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
|
||||
healthResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(healthResponse, healthRequest)
|
||||
if healthResponse.Code != http.StatusOK {
|
||||
t.Fatalf("GET /v1/health status = %d, want 200", healthResponse.Code)
|
||||
}
|
||||
|
||||
metricsRequest := httptest.NewRequest(http.MethodGet, "/v1/metrics", nil)
|
||||
metricsResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(metricsResponse, metricsRequest)
|
||||
if metricsResponse.Code != http.StatusOK {
|
||||
t.Fatalf("GET /v1/metrics status = %d, want 200", metricsResponse.Code)
|
||||
}
|
||||
|
||||
prometheusRequest := httptest.NewRequest(http.MethodGet, "/v1/metrics/prometheus", nil)
|
||||
prometheusResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(prometheusResponse, prometheusRequest)
|
||||
if prometheusResponse.Code != http.StatusOK {
|
||||
t.Fatalf("GET /v1/metrics/prometheus status = %d, want 200", prometheusResponse.Code)
|
||||
}
|
||||
|
||||
snapshot := metrics.snapshot()
|
||||
if snapshot.RequestsTotal != 1 {
|
||||
t.Fatalf("requestsTotal = %d, want 1", snapshot.RequestsTotal)
|
||||
}
|
||||
if findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/metrics", http.StatusOK) != nil {
|
||||
t.Fatal("metrics endpoint request should be excluded from tracking")
|
||||
}
|
||||
if findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/metrics/prometheus", http.StatusOK) != nil {
|
||||
t.Fatal("prometheus metrics endpoint request should be excluded from tracking")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotPrometheus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metrics := newRequestMetrics()
|
||||
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 50*time.Millisecond)
|
||||
metrics.observe(http.MethodGet, "/v1/tasks", http.StatusNotFound, 25*time.Millisecond)
|
||||
metrics.observe(http.MethodGet, `/v1/quoted"path`, http.StatusOK, 35*time.Millisecond)
|
||||
|
||||
output := metrics.snapshotPrometheus()
|
||||
expectedFragments := []string{
|
||||
"productier_http_uptime_seconds",
|
||||
`productier_http_requests_total{status_class="2xx"} 2`,
|
||||
`productier_http_requests_total{status_class="4xx"} 1`,
|
||||
`productier_http_requests_route_total{method="GET",path="/v1/health",status="200"} 1`,
|
||||
`productier_http_request_latency_avg_ms{method="GET",path="/v1/health",status="200"} 50.000`,
|
||||
`productier_http_request_latency_max_ms{method="GET",path="/v1/tasks",status="404"} 25.000`,
|
||||
`path="/v1/quoted\"path"`,
|
||||
}
|
||||
for _, fragment := range expectedFragments {
|
||||
if !strings.Contains(output, fragment) {
|
||||
t.Fatalf("expected prometheus output to contain %q\noutput:\n%s", fragment, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItoa(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[int]string{
|
||||
0: "0",
|
||||
7: "7",
|
||||
42: "42",
|
||||
-10: "-10",
|
||||
2048: "2048",
|
||||
}
|
||||
|
||||
for input, want := range cases {
|
||||
if got := itoa(input); got != want {
|
||||
t.Fatalf("itoa(%d) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findRouteMetric(routes []routeMetricSnapshot, method, path string, status int) *routeMetricSnapshot {
|
||||
for _, route := range routes {
|
||||
if route.Method == method && route.Path == path && route.Status == status {
|
||||
result := route
|
||||
return &result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
// OAuth state for CSRF protection
|
||||
type oauthState struct {
|
||||
State string `json:"state"`
|
||||
Provider string `json:"provider"`
|
||||
WorkspaceSlug string `json:"workspaceSlug"`
|
||||
RedirectURL string `json:"redirectUrl"`
|
||||
}
|
||||
|
||||
// In-memory state store (in production, use Redis or database)
|
||||
var oauthStates = make(map[string]oauthState)
|
||||
|
||||
func (s *Server) registerOAuthRoutes(group *gin.RouterGroup) {
|
||||
// Google Calendar OAuth
|
||||
group.GET("/oauth/google-calendar/connect", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
state := uuid.NewString()
|
||||
redirectURL := c.Query("redirect")
|
||||
if redirectURL == "" {
|
||||
redirectURL = fmt.Sprintf("/app/%s/integrations", workspaceSlug)
|
||||
}
|
||||
|
||||
oauthStates[state] = oauthState{
|
||||
State: state,
|
||||
Provider: "google_calendar",
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
RedirectURL: redirectURL,
|
||||
}
|
||||
|
||||
// Build Google OAuth URL
|
||||
// In production, use actual OAuth credentials from config
|
||||
authURL := fmt.Sprintf(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s&access_type=offline&prompt=consent",
|
||||
url.QueryEscape(s.config.GoogleClientID),
|
||||
url.QueryEscape(s.config.GoogleRedirectURI),
|
||||
url.QueryEscape("https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.readonly"),
|
||||
state,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"authUrl": authURL})
|
||||
})
|
||||
|
||||
group.GET("/oauth/google-calendar/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
|
||||
oauthState, exists := oauthStates[state]
|
||||
if !exists || oauthState.Provider != "google_calendar" {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "invalid oauth state")
|
||||
return
|
||||
}
|
||||
delete(oauthStates, state)
|
||||
|
||||
// Exchange code for tokens
|
||||
// In production, make actual HTTP request to Google's token endpoint
|
||||
// For now, we'll create a placeholder integration
|
||||
integration := s.store.CreateIntegration(store.CreateIntegrationInput{
|
||||
WorkspaceSlug: oauthState.WorkspaceSlug,
|
||||
Provider: "google_calendar",
|
||||
Name: "Google Calendar",
|
||||
Config: `{"calendar_id": "primary"}`,
|
||||
Credentials: code, // In production, store actual tokens
|
||||
})
|
||||
|
||||
// Redirect back to the app
|
||||
c.Redirect(http.StatusTemporaryRedirect, oauthState.RedirectURL+"?connected=google_calendar")
|
||||
})
|
||||
|
||||
// Slack OAuth
|
||||
group.GET("/oauth/slack/connect", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
state := uuid.NewString()
|
||||
redirectURL := c.Query("redirect")
|
||||
if redirectURL == "" {
|
||||
redirectURL = fmt.Sprintf("/app/%s/integrations", workspaceSlug)
|
||||
}
|
||||
|
||||
oauthStates[state] = oauthState{
|
||||
State: state,
|
||||
Provider: "slack",
|
||||
WorkspaceSlug: workspaceSlug,
|
||||
RedirectURL: redirectURL,
|
||||
}
|
||||
|
||||
// Build Slack OAuth URL
|
||||
scopes := "chat:write,channels:read,groups:read,im:read"
|
||||
authURL := fmt.Sprintf(
|
||||
"https://slack.com/oauth/v2/authorize?client_id=%s&scope=%s&redirect_uri=%s&state=%s",
|
||||
url.QueryEscape(s.config.SlackClientID),
|
||||
url.QueryEscape(scopes),
|
||||
url.QueryEscape(s.config.SlackRedirectURI),
|
||||
state,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"authUrl": authURL})
|
||||
})
|
||||
|
||||
group.GET("/oauth/slack/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
|
||||
oauthState, exists := oauthStates[state]
|
||||
if !exists || oauthState.Provider != "slack" {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "invalid oauth state")
|
||||
return
|
||||
}
|
||||
delete(oauthStates, state)
|
||||
|
||||
// Exchange code for tokens
|
||||
// In production, make actual HTTP request to Slack's token endpoint
|
||||
// For now, we'll create a placeholder integration
|
||||
integration := s.store.CreateIntegration(store.CreateIntegrationInput{
|
||||
WorkspaceSlug: oauthState.WorkspaceSlug,
|
||||
Provider: "slack",
|
||||
Name: "Slack",
|
||||
Config: `{"channel": "general"}`,
|
||||
Credentials: code, // In production, store actual tokens
|
||||
})
|
||||
|
||||
// Redirect back to the app
|
||||
c.Redirect(http.StatusTemporaryRedirect, oauthState.RedirectURL+"?connected=slack&integration_id="+integration.ID)
|
||||
})
|
||||
|
||||
// Disconnect integration
|
||||
group.POST("/integrations/:integrationId/disconnect", func(c *gin.Context) {
|
||||
integration, err := s.store.GetIntegrationByID(c.Param("integrationId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, integration.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// In production, revoke OAuth tokens with the provider
|
||||
// For Google: https://oauth2.googleapis.com/revoke?token=...
|
||||
// For Slack: https://slack.com/api/auth.revoke?token=...
|
||||
|
||||
if err := s.store.DeleteIntegration(integration.ID); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
}
|
||||
|
||||
// SyncGoogleCalendar syncs events with Google Calendar
|
||||
func (s *Server) SyncGoogleCalendar(workspaceSlug string) error {
|
||||
integrations := s.store.ListIntegrations(workspaceSlug)
|
||||
for _, integration := range integrations {
|
||||
if integration.Provider == "google_calendar" && integration.Status == "active" {
|
||||
// In production, use the stored credentials to:
|
||||
// 1. Fetch events from Google Calendar
|
||||
// 2. Create/update events in our database
|
||||
// 3. Push local events to Google Calendar
|
||||
// This would be done via the Google Calendar API client
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendSlackNotification sends a notification to Slack
|
||||
func (s *Server) SendSlackNotification(workspaceSlug, channel, message string) error {
|
||||
integrations := s.store.ListIntegrations(workspaceSlug)
|
||||
for _, integration := range integrations {
|
||||
if integration.Provider == "slack" && integration.Status == "active" {
|
||||
// Parse config to get channel
|
||||
var config struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(integration.Config), &config); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// In production, use the stored credentials to:
|
||||
// 1. Post message to Slack channel via webhook or API
|
||||
// This would be done via the Slack API client
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to parse JSON config
|
||||
func parseConfig(configStr string) map[string]interface{} {
|
||||
var config map[string]interface{}
|
||||
if err := json.NewDecoder(strings.NewReader(configStr)).Decode(&config); err != nil {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerProductivityRoutes(group *gin.RouterGroup) {
|
||||
// Inbox
|
||||
group.GET("/inbox", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListInboxItems(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.POST("/inbox", func(c *gin.Context) {
|
||||
var input store.CreateInboxItemInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
item := s.store.CreateInboxItem(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
})
|
||||
|
||||
group.POST("/inbox/:itemId/process", func(c *gin.Context) {
|
||||
var input struct {
|
||||
EntityType string `json:"entityType" binding:"required"`
|
||||
EntityID string `json:"entityId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.store.ProcessInboxItem(c.Param("itemId"), input.EntityType, input.EntityID); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
group.DELETE("/inbox/:itemId", func(c *gin.Context) {
|
||||
if err := s.store.DeleteInboxItem(c.Param("itemId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Time entries
|
||||
group.GET("/time-entries", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListTimeEntries(workspaceSlug)})
|
||||
})
|
||||
|
||||
group.POST("/time-entries", func(c *gin.Context) {
|
||||
var input store.CreateTimeEntryInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
entry := s.store.CreateTimeEntry(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": entry})
|
||||
})
|
||||
|
||||
group.PATCH("/time-entries/:entryId", func(c *gin.Context) {
|
||||
var input store.UpdateTimeEntryInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
updated, err := s.store.UpdateTimeEntry(c.Param("entryId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
group.DELETE("/time-entries/:entryId", func(c *gin.Context) {
|
||||
if err := s.store.DeleteTimeEntry(c.Param("entryId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Saved views
|
||||
group.GET("/saved-views", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
entityType := c.Query("entityType")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListSavedViews(workspaceSlug, entityType)})
|
||||
})
|
||||
|
||||
group.POST("/saved-views", func(c *gin.Context) {
|
||||
var input store.CreateSavedViewInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
view := s.store.CreateSavedView(input)
|
||||
c.JSON(http.StatusCreated, gin.H{"data": view})
|
||||
})
|
||||
|
||||
group.DELETE("/saved-views/:viewId", func(c *gin.Context) {
|
||||
if err := s.store.DeleteSavedView(c.Param("viewId")); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"productier/apps/backend/internal/authsession"
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
const sessionContextKey = "sessionUser"
|
||||
|
||||
func (s *Server) registerRoutes() {
|
||||
v1 := s.engine.Group("/v1")
|
||||
{
|
||||
v1.GET("/health", func(c *gin.Context) {
|
||||
now := time.Now().UTC()
|
||||
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
storageStatus := gin.H{
|
||||
"provider": s.files.Provider(),
|
||||
"ok": true,
|
||||
}
|
||||
if err := s.files.Probe(probeCtx); err != nil {
|
||||
storageStatus["ok"] = false
|
||||
storageStatus["error"] = err.Error()
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"ok": false,
|
||||
"mode": s.mode,
|
||||
"timestamp": now,
|
||||
"storage": storageStatus,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"mode": s.mode,
|
||||
"timestamp": now,
|
||||
"storage": storageStatus,
|
||||
})
|
||||
})
|
||||
|
||||
v1.GET("/metrics", func(c *gin.Context) {
|
||||
if !s.authorizeMetricsRequest(c) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.metrics.snapshot())
|
||||
})
|
||||
|
||||
v1.GET("/metrics/prometheus", func(c *gin.Context) {
|
||||
if !s.authorizeMetricsRequest(c) {
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(s.metrics.snapshotPrometheus()))
|
||||
})
|
||||
|
||||
v1.GET("/invites/:token", func(c *gin.Context) {
|
||||
invite, err := s.store.GetInviteByToken(c.Param("token"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": invite})
|
||||
})
|
||||
}
|
||||
|
||||
authorized := v1.Group("/")
|
||||
authorized.Use(s.requireSession())
|
||||
{
|
||||
authorized.GET("/workspaces", func(c *gin.Context) {
|
||||
user := s.sessionUser(c)
|
||||
workspaces := s.store.ListWorkspaces()
|
||||
visible := make([]store.Workspace, 0, len(workspaces))
|
||||
for _, workspace := range workspaces {
|
||||
if _, ok := s.requireWorkspaceMemberByEmail(workspace.Slug, user.Email); ok {
|
||||
visible = append(visible, workspace)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": visible})
|
||||
})
|
||||
|
||||
authorized.GET("/members", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMembers(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.PATCH("/members/:memberId", func(c *gin.Context) {
|
||||
member, err := s.store.GetMemberByID(c.Param("memberId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
actingMember, ok := s.requireWorkspaceMember(c, member.WorkspaceSlug)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if actingMember.Role != "owner" && actingMember.Role != "admin" {
|
||||
s.writeStatusError(c, http.StatusForbidden, "member management permissions required")
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateMemberInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateMember(member.ID, input)
|
||||
if err != nil {
|
||||
switch err.Error() {
|
||||
case "invalid member role", "invalid member status":
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
case "workspace must have at least one active owner":
|
||||
s.writeStatusError(c, http.StatusConflict, err.Error())
|
||||
default:
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
authorized.GET("/invites", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListInvites(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/invites", func(c *gin.Context) {
|
||||
var input store.CreateInviteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
member, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if member.Role != "owner" && member.Role != "admin" {
|
||||
s.writeStatusError(c, http.StatusForbidden, "invite permissions required")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateInvite(input)})
|
||||
})
|
||||
|
||||
authorized.POST("/invites/:token/revoke", func(c *gin.Context) {
|
||||
invite, err := s.store.GetInviteByID(c.Param("token"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
member, ok := s.requireWorkspaceMember(c, invite.WorkspaceSlug)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if member.Role != "owner" && member.Role != "admin" {
|
||||
s.writeStatusError(c, http.StatusForbidden, "invite permissions required")
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeInvite(invite.ID); err != nil {
|
||||
switch err.Error() {
|
||||
case "only pending invites can be revoked":
|
||||
s.writeStatusError(c, http.StatusConflict, err.Error())
|
||||
default:
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
authorized.POST("/invites/:token/accept", func(c *gin.Context) {
|
||||
var input store.AcceptInviteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user := s.sessionUser(c)
|
||||
invite, err := s.store.AcceptInvite(c.Param("token"), store.AcceptInviteInput{
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": invite})
|
||||
})
|
||||
|
||||
authorized.GET("/activity", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if strings.TrimSpace(workspaceSlug) == "" {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "workspaceSlug is required")
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
params, err := parseActivityListParams(c.Query("limit"), c.Query("type"), c.Query("q"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
activities := s.store.ListActivities(workspaceSlug)
|
||||
c.JSON(http.StatusOK, gin.H{"data": filterActivityEntries(activities, params)})
|
||||
})
|
||||
|
||||
authorized.GET("/board-groups", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListBoardGroups(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/board-groups", func(c *gin.Context) {
|
||||
var input store.CreateBoardGroupInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateBoardGroup(input)})
|
||||
})
|
||||
|
||||
authorized.PATCH("/board-groups/:groupId", func(c *gin.Context) {
|
||||
group, err := s.store.GetBoardGroupByID(c.Param("groupId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, group.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateBoardGroupInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateBoardGroup(c.Param("groupId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
authorized.GET("/labels", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListLabels(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/labels", func(c *gin.Context) {
|
||||
var input store.CreateLabelInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateLabel(input)})
|
||||
})
|
||||
|
||||
authorized.GET("/tasks", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListTasks(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/tasks", func(c *gin.Context) {
|
||||
var input store.CreateTaskInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
task := s.store.CreateTask(input)
|
||||
|
||||
// Trigger webhooks for task creation
|
||||
s.store.TriggerWebhooks(input.WorkspaceSlug, "task.created", map[string]interface{}{
|
||||
"taskId": task.ID,
|
||||
"title": task.Title,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": task})
|
||||
})
|
||||
|
||||
authorized.PATCH("/tasks/:taskId", func(c *gin.Context) {
|
||||
task, err := s.store.GetTaskByID(c.Param("taskId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateTaskInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Check if assignee is being set (task assignment notification)
|
||||
if input.AssigneeID != nil && *input.AssigneeID != "" {
|
||||
// Get assignee email from member ID
|
||||
members := s.store.ListMembers(task.WorkspaceSlug)
|
||||
for _, member := range members {
|
||||
if member.ID == *input.AssigneeID && member.Status == "active" {
|
||||
// Create notification for the assignee
|
||||
s.store.CreateNotificationForTaskAssignment(
|
||||
task.WorkspaceSlug,
|
||||
member.Email,
|
||||
task.Title,
|
||||
task.ID,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if status is being changed to done (task completion notification)
|
||||
if input.Status != nil && *input.Status == "done" && task.Status != "done" && task.AssigneeID != nil {
|
||||
// Notify the task creator or workspace owner
|
||||
members := s.store.ListMembers(task.WorkspaceSlug)
|
||||
for _, member := range members {
|
||||
if member.Role == "owner" || member.Role == "admin" {
|
||||
s.store.CreateNotificationForTaskCompletion(
|
||||
task.WorkspaceSlug,
|
||||
member.Email,
|
||||
task.Title,
|
||||
task.ID,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateTask(c.Param("taskId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger webhooks for task updates
|
||||
s.store.TriggerWebhooks(task.WorkspaceSlug, "task.updated", map[string]interface{}{
|
||||
"taskId": task.ID,
|
||||
"title": task.Title,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
authorized.GET("/calendar/events", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListEvents(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/calendar/events", func(c *gin.Context) {
|
||||
var input store.CreateEventInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateEvent(input)})
|
||||
})
|
||||
|
||||
authorized.PATCH("/calendar/events/:eventId", func(c *gin.Context) {
|
||||
event, err := s.store.GetEventByID(c.Param("eventId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, event.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateEventInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateEvent(c.Param("eventId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
authorized.GET("/notes", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListNotes(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/notes", func(c *gin.Context) {
|
||||
var input store.CreateNoteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateNote(input)})
|
||||
})
|
||||
|
||||
authorized.PATCH("/notes/:noteId", func(c *gin.Context) {
|
||||
note, err := s.store.GetNoteByID(c.Param("noteId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, note.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateNoteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateNote(c.Param("noteId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
authorized.GET("/focus/sessions", func(c *gin.Context) {
|
||||
workspaceSlug := c.Query("workspaceSlug")
|
||||
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s.store.ListFocusSessions(workspaceSlug)})
|
||||
})
|
||||
|
||||
authorized.POST("/focus/sessions", func(c *gin.Context) {
|
||||
var input store.CreateFocusSessionInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateFocusSession(input)})
|
||||
})
|
||||
|
||||
authorized.PATCH("/focus/sessions/:sessionId", func(c *gin.Context) {
|
||||
session, err := s.store.GetFocusSessionByID(c.Param("sessionId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, session.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input store.UpdateFocusSessionInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateFocusSession(c.Param("sessionId"), input)
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
})
|
||||
|
||||
s.registerTaskAttachmentRoutes(authorized)
|
||||
s.registerMailRoutes(authorized)
|
||||
s.registerCRMRoutes(authorized)
|
||||
s.registerProductivityRoutes(authorized)
|
||||
s.registerIntegrationRoutes(authorized)
|
||||
s.registerOAuthRoutes(authorized)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) requireSession() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, err := s.authClient.GetUser(c.Request.Context(), c.GetHeader("Cookie"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "session lookup failed")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(sessionContextKey, user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sessionUser(c *gin.Context) *authsession.User {
|
||||
value, exists := c.Get(sessionContextKey)
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
user, _ := value.(*authsession.User)
|
||||
return user
|
||||
}
|
||||
|
||||
func (s *Server) requireWorkspaceMember(c *gin.Context, workspaceSlug string) (store.Member, bool) {
|
||||
user := s.sessionUser(c)
|
||||
if user == nil {
|
||||
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
|
||||
return store.Member{}, false
|
||||
}
|
||||
member, ok := s.requireWorkspaceMemberByEmail(workspaceSlug, user.Email)
|
||||
if !ok {
|
||||
s.writeStatusError(c, http.StatusForbidden, "workspace membership required")
|
||||
return store.Member{}, false
|
||||
}
|
||||
return member, true
|
||||
}
|
||||
|
||||
func (s *Server) requireWorkspaceMemberByEmail(workspaceSlug string, email string) (store.Member, bool) {
|
||||
for _, member := range s.store.ListMembers(workspaceSlug) {
|
||||
if strings.EqualFold(member.Email, email) && member.Status == "active" {
|
||||
return member, true
|
||||
}
|
||||
}
|
||||
return store.Member{}, false
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"productier/apps/backend/internal/authsession"
|
||||
"productier/apps/backend/internal/filestorage"
|
||||
"productier/apps/backend/internal/mailruntime"
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
func getEnvOrDefault(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
type OAuthConfig struct {
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
GoogleRedirectURI string
|
||||
SlackClientID string
|
||||
SlackClientSecret string
|
||||
SlackRedirectURI string
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
engine *gin.Engine
|
||||
mode string
|
||||
store store.Store
|
||||
authClient *authsession.Client
|
||||
mail *mailruntime.Service
|
||||
files filestorage.Storage
|
||||
metrics *requestMetrics
|
||||
metricsToken string
|
||||
config OAuthConfig
|
||||
}
|
||||
|
||||
func NewServer(
|
||||
dataStore store.Store,
|
||||
authClient *authsession.Client,
|
||||
mailService *mailruntime.Service,
|
||||
fileStorage filestorage.Storage,
|
||||
mode string,
|
||||
corsAllowOrigins []string,
|
||||
metricsToken string,
|
||||
logger *zap.Logger,
|
||||
) *Server {
|
||||
engine := gin.New()
|
||||
metrics := newRequestMetrics()
|
||||
engine.Use(gin.Recovery())
|
||||
engine.Use(requestMetricsMiddleware(metrics))
|
||||
engine.Use(requestLogMiddleware(logger))
|
||||
engine.Use(requestIDMiddleware())
|
||||
engine.Use(cors.New(cors.Config{
|
||||
AllowOrigins: corsAllowOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete, http.MethodOptions},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "Cookie"},
|
||||
ExposeHeaders: []string{"Content-Length", "X-Request-Id"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
server := &Server{
|
||||
engine: engine,
|
||||
mode: mode,
|
||||
store: dataStore,
|
||||
authClient: authClient,
|
||||
mail: mailService,
|
||||
files: fileStorage,
|
||||
metrics: metrics,
|
||||
metricsToken: metricsToken,
|
||||
config: OAuthConfig{
|
||||
GoogleClientID: getEnvOrDefault("GOOGLE_CLIENT_ID", ""),
|
||||
GoogleClientSecret: getEnvOrDefault("GOOGLE_CLIENT_SECRET", ""),
|
||||
GoogleRedirectURI: getEnvOrDefault("GOOGLE_REDIRECT_URI", "http://localhost:8080/v1/oauth/google-calendar/callback"),
|
||||
SlackClientID: getEnvOrDefault("SLACK_CLIENT_ID", ""),
|
||||
SlackClientSecret: getEnvOrDefault("SLACK_CLIENT_SECRET", ""),
|
||||
SlackRedirectURI: getEnvOrDefault("SLACK_REDIRECT_URI", "http://localhost:8080/v1/oauth/slack/callback"),
|
||||
},
|
||||
}
|
||||
|
||||
server.registerRoutes()
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *Server) Engine() *gin.Engine {
|
||||
return s.engine
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"productier/apps/backend/internal/filestorage"
|
||||
"productier/apps/backend/internal/store"
|
||||
)
|
||||
|
||||
const maxTaskAttachmentBytes int64 = 20 << 20 // 20 MB
|
||||
|
||||
func (s *Server) registerTaskAttachmentRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/tasks/:taskId/attachments", func(c *gin.Context) {
|
||||
task, err := s.store.GetTaskByID(c.Param("taskId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "file is required")
|
||||
return
|
||||
}
|
||||
if file.Size <= 0 {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "file is empty")
|
||||
return
|
||||
}
|
||||
if file.Size > maxTaskAttachmentBytes {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "file exceeds 20MB limit")
|
||||
return
|
||||
}
|
||||
|
||||
attachmentID := uuid.NewString()
|
||||
objectKey := taskAttachmentObjectKey(task.ID, attachmentID)
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusBadRequest, "unable to read uploaded file")
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
mimeType := file.Header.Get("Content-Type")
|
||||
if strings.TrimSpace(mimeType) == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
if err := s.files.Put(c.Request.Context(), objectKey, src, mimeType, file.Size); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, "failed to store uploaded file")
|
||||
return
|
||||
}
|
||||
attachment := store.Attachment{
|
||||
ID: attachmentID,
|
||||
Name: cleanAttachmentName(file.Filename),
|
||||
MimeType: mimeType,
|
||||
Size: int(file.Size),
|
||||
DataURL: fmt.Sprintf("/v1/tasks/%s/attachments/%s/download", task.ID, attachmentID),
|
||||
}
|
||||
|
||||
attachments := append([]store.Attachment{attachment}, task.Attachments...)
|
||||
if _, err := s.store.UpdateTask(task.ID, store.UpdateTaskInput{Attachments: attachments}); err != nil {
|
||||
_ = s.files.Delete(c.Request.Context(), objectKey)
|
||||
s.writeStatusError(c, http.StatusInternalServerError, "failed to save task attachment")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": attachment})
|
||||
})
|
||||
|
||||
group.GET("/tasks/:taskId/attachments/:attachmentId/download", func(c *gin.Context) {
|
||||
task, err := s.store.GetTaskByID(c.Param("taskId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
attachmentID := c.Param("attachmentId")
|
||||
var attachment *store.Attachment
|
||||
for index := range task.Attachments {
|
||||
if task.Attachments[index].ID == attachmentID {
|
||||
attachment = &task.Attachments[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if attachment == nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, "attachment not found")
|
||||
return
|
||||
}
|
||||
|
||||
object, err := s.files.Get(c.Request.Context(), taskAttachmentObjectKey(task.ID, attachmentID))
|
||||
if err != nil {
|
||||
if err == filestorage.ErrNotFound {
|
||||
s.writeStatusError(c, http.StatusNotFound, "attachment file not found")
|
||||
return
|
||||
}
|
||||
s.writeStatusError(c, http.StatusInternalServerError, "failed to read attachment file")
|
||||
return
|
||||
}
|
||||
defer object.Body.Close()
|
||||
|
||||
c.Header("Content-Type", firstNonBlank(object.ContentType, attachment.MimeType, "application/octet-stream"))
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", cleanAttachmentName(attachment.Name)))
|
||||
c.Status(http.StatusOK)
|
||||
if _, err := io.Copy(c.Writer, object.Body); err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
group.DELETE("/tasks/:taskId/attachments/:attachmentId", func(c *gin.Context) {
|
||||
task, err := s.store.GetTaskByID(c.Param("taskId"))
|
||||
if err != nil {
|
||||
s.writeStatusError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
attachmentID := c.Param("attachmentId")
|
||||
index := -1
|
||||
for i := range task.Attachments {
|
||||
if task.Attachments[i].ID == attachmentID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
s.writeStatusError(c, http.StatusNotFound, "attachment not found")
|
||||
return
|
||||
}
|
||||
|
||||
nextAttachments := make([]store.Attachment, 0, len(task.Attachments)-1)
|
||||
nextAttachments = append(nextAttachments, task.Attachments[:index]...)
|
||||
nextAttachments = append(nextAttachments, task.Attachments[index+1:]...)
|
||||
if _, err := s.store.UpdateTask(task.ID, store.UpdateTaskInput{Attachments: nextAttachments}); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, "failed to update task attachments")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.files.Delete(c.Request.Context(), taskAttachmentObjectKey(task.ID, attachmentID)); err != nil {
|
||||
s.writeStatusError(c, http.StatusInternalServerError, "attachment metadata updated but file cleanup failed")
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
func taskAttachmentObjectKey(taskID string, attachmentID string) string {
|
||||
return fmt.Sprintf("tasks/%s/%s", taskID, attachmentID)
|
||||
}
|
||||
|
||||
func cleanAttachmentName(name string) string {
|
||||
cleaned := strings.TrimSpace(filepath.Base(name))
|
||||
if cleaned == "" || cleaned == "." || cleaned == "/" {
|
||||
return "attachment"
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
Reference in New Issue
Block a user