first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:04:09 +02:00
commit 3cb40adb23
203 changed files with 40226 additions and 0 deletions
@@ -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)
}
}
}
+214
View File
@@ -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})
})
}
+62
View File
@@ -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})
})
}
+57
View File
@@ -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
}
+263
View File
@@ -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})
})
}
+570
View File
@@ -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
}
+96
View File
@@ -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
}