mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-03 20:13:01 +00:00
571 lines
16 KiB
Go
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
|
|
}
|