Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions api/internal/handler/attachment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package handler

import (
"net/http"

"github.com/Devlaner/devlane/api/internal/middleware"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)

// AttachmentHandler serves file attachment endpoints for issues.
type AttachmentHandler struct {
Attachment *service.AttachmentService
}

// InitiateUpload creates the asset/attachment records and returns a presigned upload URL.
// POST /api/assets/v2/workspaces/:slug/projects/:projectId/issues/:issueId/attachments/
func (h *AttachmentHandler) InitiateUpload(c *gin.Context) {
user := middleware.GetUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
slug := c.Param("slug")
projectID, err := uuid.Parse(c.Param("projectId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
issueID, err := uuid.Parse(c.Param("issueId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid issue ID"})
return
}
var body struct {
Name string `json:"name" binding:"required"`
Size float64 `json:"size"`
Type string `json:"type"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
return
}
resp, err := h.Attachment.InitiateUpload(c.Request.Context(), slug, projectID, issueID, user.ID, body.Name, body.Size, body.Type)
if err != nil {
if err == service.ErrProjectForbidden || err == service.ErrProjectNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
if err.Error() == "file storage is not configured" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "File storage is not configured"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initiate upload"})
return
}
c.JSON(http.StatusCreated, resp)
}

// ConfirmUpload marks the upload as complete.
// PATCH /api/assets/v2/workspaces/:slug/projects/:projectId/issues/:issueId/attachments/:assetId/
func (h *AttachmentHandler) ConfirmUpload(c *gin.Context) {
user := middleware.GetUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
slug := c.Param("slug")
projectID, err := uuid.Parse(c.Param("projectId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
issueID, err := uuid.Parse(c.Param("issueId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid issue ID"})
return
}
assetID, err := uuid.Parse(c.Param("assetId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid asset ID"})
return
}
if err := h.Attachment.ConfirmUpload(c.Request.Context(), slug, projectID, issueID, assetID, user.ID); err != nil {
if err == service.ErrAttachmentNotFound || err == service.ErrProjectForbidden {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to confirm upload"})
return
}
c.Status(http.StatusNoContent)
}

// ListAttachments returns uploaded attachments for an issue.
// GET /api/assets/v2/workspaces/:slug/projects/:projectId/issues/:issueId/attachments/
func (h *AttachmentHandler) ListAttachments(c *gin.Context) {
user := middleware.GetUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
slug := c.Param("slug")
projectID, err := uuid.Parse(c.Param("projectId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
issueID, err := uuid.Parse(c.Param("issueId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid issue ID"})
return
}
list, err := h.Attachment.ListAttachments(c.Request.Context(), slug, projectID, issueID, user.ID)
if err != nil {
if err == service.ErrProjectForbidden || err == service.ErrProjectNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list attachments"})
return
}
if list == nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
c.JSON(http.StatusOK, list)
}

// DeleteAttachment removes an attachment.
// DELETE /api/assets/v2/workspaces/:slug/projects/:projectId/issues/:issueId/attachments/:assetId/
func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) {
user := middleware.GetUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
slug := c.Param("slug")
projectID, err := uuid.Parse(c.Param("projectId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
issueID, err := uuid.Parse(c.Param("issueId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid issue ID"})
return
}
assetID, err := uuid.Parse(c.Param("assetId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid asset ID"})
return
}
if err := h.Attachment.DeleteAttachment(c.Request.Context(), slug, projectID, issueID, assetID, user.ID); err != nil {
if err == service.ErrAttachmentNotFound || err == service.ErrProjectForbidden {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete attachment"})
return
}
c.Status(http.StatusNoContent)
}
40 changes: 40 additions & 0 deletions api/internal/handler/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,46 @@ func (h *CycleHandler) AddIssue(c *gin.Context) {
c.Status(http.StatusNoContent)
}

// Progress returns a TProgressSnapshot for the cycle (burndown data).
// GET /api/workspaces/:slug/projects/:projectId/cycles/:cycleId/progress/
// GET /api/workspaces/:slug/projects/:projectId/cycles/:cycleId/cycle-progress/
func (h *CycleHandler) Progress(c *gin.Context) {
user := middleware.GetUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
slug := c.Param("slug")
projectID, err := uuid.Parse(c.Param("projectId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
cycleID, err := uuid.Parse(c.Param("cycleId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cycle ID"})
return
}
snap, err := h.Cycle.GetProgress(c.Request.Context(), slug, projectID, cycleID, user.ID)
if err != nil {
if err == service.ErrCycleNotFound || err == service.ErrProjectForbidden || err == service.ErrProjectNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cycle progress"})
return
}
c.JSON(http.StatusOK, snap)
}

// Analytics returns the distribution analytics for the cycle.
// GET /api/workspaces/:slug/projects/:projectId/cycles/:cycleId/analytics
func (h *CycleHandler) Analytics(c *gin.Context) {
// For now, analytics returns the same progress snapshot so the frontend
// completion_chart renders. The ?type query param is accepted but ignored.
h.Progress(c)
}

// RemoveIssue unlinks an issue from the cycle.
// DELETE /api/workspaces/:slug/projects/:projectId/cycles/:cycleId/issues/:issueId/
func (h *CycleHandler) RemoveIssue(c *gin.Context) {
Expand Down
62 changes: 62 additions & 0 deletions api/internal/handler/cycle_progress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package handler_test

import (
"net/http"
"testing"

"github.com/Devlaner/devlane/api/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func cycleProgressURL(slug, projectID, cycleID string) string {
return "/api/workspaces/" + slug + "/projects/" + projectID + "/cycles/" + cycleID + "/progress/"
}

func cycleAnalyticsURL(slug, projectID, cycleID string) string {
return "/api/workspaces/" + slug + "/projects/" + projectID + "/cycles/" + cycleID + "/analytics"
}

func TestCycleProgress_RequiresAuth(t *testing.T) {
ts := testutil.NewTestServer(t)
nilID := "00000000-0000-0000-0000-000000000000"
rr := ts.GET(cycleProgressURL("x", nilID, nilID), "")
require.Equal(t, http.StatusUnauthorized, rr.Code)
}

func TestCycleProgress_EmptyCycle(t *testing.T) {
ts := testutil.NewTestServer(t)
w := testutil.SeedWorld(t, ts.DB)
cycle := testutil.CreateCycle(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID)

rr := ts.GET(cycleProgressURL(w.Workspace.Slug, w.Project.ID.String(), cycle.ID.String()), w.Session)
require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String())

body := testutil.MustJSONMap(t, rr)
assert.Equal(t, float64(0), body["total_issues"])
assert.Equal(t, float64(0), body["completed_issues"])
dist, ok := body["distribution"].(map[string]interface{})
require.True(t, ok, "distribution should be present")
_, hasChart := dist["completion_chart"]
assert.True(t, hasChart, "completion_chart should be in distribution")
}

func TestCycleAnalytics_ReturnsProgress(t *testing.T) {
ts := testutil.NewTestServer(t)
w := testutil.SeedWorld(t, ts.DB)
cycle := testutil.CreateCycle(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID)

rr := ts.GET(cycleAnalyticsURL(w.Workspace.Slug, w.Project.ID.String(), cycle.ID.String())+"?type=points", w.Session)
require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String())
}

func TestCycleProgress_NonMember404(t *testing.T) {
ts := testutil.NewTestServer(t)
w := testutil.SeedWorld(t, ts.DB)
cycle := testutil.CreateCycle(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID)
stranger := testutil.CreateUser(t, ts.DB)
strangerSession := testutil.LoginAs(t, ts.DB, stranger)

rr := ts.GET(cycleProgressURL(w.Workspace.Slug, w.Project.ID.String(), cycle.ID.String()), strangerSession)
require.Equal(t, http.StatusNotFound, rr.Code)
}
Loading
Loading