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 }