Files
Productier/apps/backend/internal/httpapi/routes.go
T
Tomas Dvorak 3cb40adb23 first commit
2026-04-10 12:04:09 +02:00

571 lines
16 KiB
Go

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
}