Skip to content
Open
309 changes: 309 additions & 0 deletions apps/api/internal/handler/analytics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
package handler

import (
"encoding/csv"
"fmt"
"log/slog"
"net/http"
"time"

"github.com/gin-gonic/gin"
"gorm.io/gorm"
)

type AnalyticsHandler struct {
DB *gorm.DB
Log *slog.Logger
Comment on lines +14 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Move these queries behind an authorization-aware service.

All four handlers query using route parameters without checking that the authenticated user belongs to the workspace/project; the project handlers also ignore slug. Any authenticated user can therefore access another tenant’s analytics or exports.

Resolve the current user in the handler, enforce workspace/project access in a service, and place GORM queries in a store.

As per coding guidelines, “handlers call services, services call stores; handlers must never touch GORM directly.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/internal/handler/analytics.go` around lines 14 - 16, Refactor
AnalyticsHandler and all four analytics/export handlers so they resolve the
authenticated user and delegate authorization-aware workspace/project access
checks to a service, including validating the project slug. Move every GORM
query out of the handlers into a store called by that service, preserving the
existing response behavior only after access is authorized.

Source: Coding guidelines

}

type AnalyticsResponse struct {
ByState map[string]int64 `json:"by_state"`
ByPriority map[string]int64 `json:"by_priority"`
ByAssignee map[string]int64 `json:"by_assignee"`
ByLabel map[string]int64 `json:"by_label"`
}
Comment on lines +19 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Implement the created-versus-resolved analytics contract end to end.

The backend exposes no trend series, while the frontend permanently supplies an empty array, so the requested chart can never display data.

  • apps/api/internal/handler/analytics.go#L19-L24: add typed created/resolved time-series data to the response and populate it.
  • apps/web/src/pages/AnalyticsWorkItemsPage.tsx#L246-L246: consume that field using a concrete type instead of any[].
📍 Affects 2 files
  • apps/api/internal/handler/analytics.go#L19-L24 (this comment)
  • apps/web/src/pages/AnalyticsWorkItemsPage.tsx#L246-L246
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/internal/handler/analytics.go` around lines 19 - 24, The analytics
response contract lacks populated created-versus-resolved trend data. In
apps/api/internal/handler/analytics.go:19-24, extend AnalyticsResponse with a
typed time-series field and populate it through the analytics handler’s existing
data flow; in apps/web/src/pages/AnalyticsWorkItemsPage.tsx:246, consume that
response field with the corresponding concrete type instead of any[] so the
chart receives real backend data.

Source: Linters/SAST tools


func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) {
slug := c.Param("slug")

// 1. Counts by State
var stateResults []struct {
State string
Count int64
}
err := h.DB.Table("issues").
Select("states.name as state, count(issues.id) as count").
Joins("INNER JOIN states ON issues.state_id = states.id").
Joins("INNER JOIN workspaces ON issues.workspace_id = workspaces.id").
Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND states.deleted_at IS NULL", slug).
Group("states.name").
Scan(&stateResults).Error

if err != nil {
h.Log.Error("failed to fetch workspace state analytics", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}

byState := make(map[string]int64)
for _, r := range stateResults {
byState[r.State] = r.Count
}

// 2. Counts by Priority
var priorityResults []struct {
Priority string
Count int64
}
err = h.DB.Table("issues").
Select("issues.priority, count(issues.id) as count").
Joins("INNER JOIN workspaces ON issues.workspace_id = workspaces.id").
Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND issues.priority IS NOT NULL AND issues.priority != ''", slug).
Group("issues.priority").
Scan(&priorityResults).Error

if err != nil {
h.Log.Error("failed to fetch workspace priority analytics", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}

byPriority := make(map[string]int64)
for _, r := range priorityResults {
byPriority[r.Priority] = r.Count
}

// 3. Counts by Assignee (Many-to-Many via issue_assignees)
var assigneeResults []struct {
Email string
Count int64
}
err = h.DB.Table("issue_assignees").
Select("users.email as email, count(issue_assignees.issue_id) as count").
Joins("INNER JOIN users ON issue_assignees.user_id = users.id").
Joins("INNER JOIN issues ON issue_assignees.issue_id = issues.id").
Joins("INNER JOIN workspaces ON issues.workspace_id = workspaces.id").
Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL", slug).
Group("users.email").
Scan(&assigneeResults).Error

byAssignee := make(map[string]int64)
if err == nil {
for _, r := range assigneeResults {
byAssignee[r.Email] = r.Count
}
} else {
h.Log.Warn("failed to fetch workspace assignee analytics", "error", err)
}

// 4. Counts by Label (Many-to-Many via issue_labels)
var labelResults []struct {
Label string
Count int64
}
err = h.DB.Table("issue_labels").
Select("labels.name as label, count(issue_labels.issue_id) as count").
Joins("INNER JOIN labels ON issue_labels.label_id = labels.id").
Joins("INNER JOIN issues ON issue_labels.issue_id = issues.id").
Joins("INNER JOIN workspaces ON issues.workspace_id = workspaces.id").
Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND labels.deleted_at IS NULL", slug).
Group("labels.name").
Scan(&labelResults).Error

byLabel := make(map[string]int64)
if err == nil {
for _, r := range labelResults {
byLabel[r.Label] = r.Count
}
} else {
h.Log.Warn("failed to fetch workspace label analytics", "error", err)
}
Comment on lines +90 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not report failed aggregate queries as zero counts.

Assignee/label failures—and all secondary project-query failures—currently produce a successful response containing empty maps. Return an error or expose an explicit partial-result status so database failures cannot be mistaken for valid zero metrics.

Also applies to: 170-215

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/internal/handler/analytics.go` around lines 90 - 120, Update the
analytics handler’s aggregate-query error handling, including the assignee and
label flows and the secondary project-query section, so any database failure is
returned or surfaced through an explicit partial-result status instead of
leaving empty maps that imply zero counts. Preserve successful aggregation
behavior and ensure the response clearly distinguishes valid empty metrics from
failed queries.


c.JSON(http.StatusOK, AnalyticsResponse{
ByState: byState,
ByPriority: byPriority,
ByAssignee: byAssignee,
ByLabel: byLabel,
})
}

func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) {
projectID := c.Param("projectId")
if projectID == "" {
projectID = c.Param("projectID")
}

// 1. Project Counts by State
var stateResults []struct {
State string
Count int64
}
err := h.DB.Table("issues").
Select("states.name as state, count(issues.id) as count").
Joins("INNER JOIN states ON issues.state_id = states.id").
Where("issues.project_id = ? AND issues.deleted_at IS NULL AND states.deleted_at IS NULL", projectID).
Group("states.name").
Scan(&stateResults).Error

if err != nil {
h.Log.Error("failed to fetch project analytics", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}

byState := make(map[string]int64)
for _, r := range stateResults {
byState[r.State] = r.Count
}

// 2. Project Counts by Priority
var priorityResults []struct {
Priority string
Count int64
}
err = h.DB.Table("issues").
Select("issues.priority, count(issues.id) as count").
Where("issues.project_id = ? AND issues.deleted_at IS NULL AND issues.priority IS NOT NULL AND issues.priority != ''", projectID).
Group("issues.priority").
Scan(&priorityResults).Error

byPriority := make(map[string]int64)
if err == nil {
for _, r := range priorityResults {
byPriority[r.Priority] = r.Count
}
}

// 3. Project Counts by Assignee
var assigneeResults []struct {
Email string
Count int64
}
err = h.DB.Table("issue_assignees").
Select("users.email as email, count(issue_assignees.issue_id) as count").
Joins("INNER JOIN users ON issue_assignees.user_id = users.id").
Joins("INNER JOIN issues ON issue_assignees.issue_id = issues.id").
Where("issues.project_id = ? AND issues.deleted_at IS NULL", projectID).
Group("users.email").
Scan(&assigneeResults).Error

byAssignee := make(map[string]int64)
if err == nil {
for _, r := range assigneeResults {
byAssignee[r.Email] = r.Count
}
}

// 4. Project Counts by Label
var labelResults []struct {
Label string
Count int64
}
err = h.DB.Table("issue_labels").
Select("labels.name as label, count(issue_labels.issue_id) as count").
Joins("INNER JOIN labels ON issue_labels.label_id = labels.id").
Joins("INNER JOIN issues ON issue_labels.issue_id = issues.id").
Where("issues.project_id = ? AND issues.deleted_at IS NULL AND labels.deleted_at IS NULL", projectID).
Group("labels.name").
Scan(&labelResults).Error

byLabel := make(map[string]int64)
if err == nil {
for _, r := range labelResults {
byLabel[r.Label] = r.Count
}
}

c.JSON(http.StatusOK, AnalyticsResponse{
ByState: byState,
ByPriority: byPriority,
ByAssignee: byAssignee,
ByLabel: byLabel,
})
}

func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) {
slug := c.Param("slug")

var issues []struct {
ID string
Name string
State string
Priority string
}

err := h.DB.Table("issues").
Select("issues.id, issues.name, states.name as state, issues.priority").
Joins("INNER JOIN states ON issues.state_id = states.id").
Joins("INNER JOIN workspaces ON issues.workspace_id = workspaces.id").
Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND states.deleted_at IS NULL", slug).
Scan(&issues).Error

if err != nil {
h.Log.Error("failed to fetch workspace issues for CSV export", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch workspace data"})
return
}

filename := fmt.Sprintf("workspace-%s-analytics-%s.csv", slug, time.Now().Format("2006-01-02"))
c.Header("Content-Description", "File Transfer")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Header("Content-Type", "text/csv")
c.Header("Content-Transfer-Encoding", "binary")

writer := csv.NewWriter(c.Writer)
defer writer.Flush()

_ = writer.Write([]string{"Issue ID", "Title", "State", "Priority"})

for _, issue := range issues {
_ = writer.Write([]string{
issue.ID,
issue.Name,
issue.State,
issue.Priority,
})
Comment on lines +257 to +265

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Neutralize spreadsheet formulas in exported cells.

Issue titles, state names, and priorities can begin with formula markers such as =, +, -, or @. Prefix unsafe values before passing them to csv.Writer; CSV quoting alone does not prevent spreadsheet execution.

Also applies to: 300-307

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/internal/handler/analytics.go` around lines 257 - 265, Update the
issue row export in the analytics CSV handler to neutralize spreadsheet formulas
in user-controlled fields before passing them to csv.Writer. Apply the same
sanitization to issue.Name, issue.State, and issue.Priority, and also to the
corresponding cells in the referenced export block around lines 300-307;
preserve IDs and headers unless they are subject to the same unsafe-value
requirement.

}
Comment on lines +254 to +266

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Check CSV write and flush errors.

Every Write error is discarded, and deferred Flush prevents checking writer.Error(). A disconnected client can receive a truncated file presented as a successful export.

Proposed pattern
- defer writer.Flush()
+ defer writer.Flush()

- _ = writer.Write(headers)
+ if err := writer.Write(headers); err != nil {
+     h.Log.Error("failed to write CSV header", "error", err)
+     return
+ }

+ writer.Flush()
+ if err := writer.Error(); err != nil {
+     h.Log.Error("failed to flush CSV export", "error", err)
+ }

Also applies to: 297-307

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/internal/handler/analytics.go` around lines 254 - 266, Update the
CSV export handler around csv.Writer creation to check errors from both the
header and per-issue writer.Write calls, and ensure writer.Flush is executed
before inspecting writer.Error(). Propagate or return an appropriate error when
any write or flush error occurs, including the analogous export block around the
second issue loop.

}

func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) {
projectID := c.Param("projectId")
if projectID == "" {
projectID = c.Param("projectID")
}

var issues []struct {
ID string
Name string
State string
}

err := h.DB.Table("issues").
Select("issues.id, issues.name, states.name as state").
Joins("INNER JOIN states ON issues.state_id = states.id").
Where("issues.project_id = ? AND issues.deleted_at IS NULL AND states.deleted_at IS NULL", projectID).
Scan(&issues).Error

if err != nil {
h.Log.Error("failed to fetch project issues for CSV export", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project data"})
return
}

filename := fmt.Sprintf("project-%s-analytics-%s.csv", projectID, time.Now().Format("2006-01-02"))
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Header("Content-Type", "text/csv")

writer := csv.NewWriter(c.Writer)
defer writer.Flush()

_ = writer.Write([]string{"Project Issue ID", "Title", "State"})

for _, issue := range issues {
_ = writer.Write([]string{
issue.ID,
issue.Name,
issue.State,
})
}
}
14 changes: 13 additions & 1 deletion apps/api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,12 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) {
Queue: cfg.Queue,
AppBaseURL: appBaseURL,
}

analyticsHandler := &handler.AnalyticsHandler{
DB: cfg.DB,
Log: cfg.Log,
}

projectHandler := &handler.ProjectHandler{Project: projectSvc, State: stateSvc}
notifPrefHandler := &handler.NotificationPreferenceHandler{Prefs: userNotifPrefStore, Ws: workspaceStore, Projects: projectSvc}
favoriteSvc := service.NewFavoriteService(userFavoriteStore, workspaceStore, projectSvc)
Expand Down Expand Up @@ -424,6 +430,12 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) {
api.POST("/workspaces/:slug/projects/:projectId/issues-bulk/delete/", issueHandler.BulkDelete)
api.POST("/workspaces/:slug/projects/:projectId/issues-bulk/reorder/", issueHandler.BulkReorder)

api.GET("/workspaces/:slug/analytics/", analyticsHandler.GetWorkspaceAnalytics)
api.GET("/workspaces/:slug/analytics/export/", analyticsHandler.ExportWorkspaceCSV)

api.GET("/workspaces/:slug/projects/:projectId/analytics", analyticsHandler.GetProjectAnalytics)
api.GET("/workspaces/:slug/projects/:projectId/analytics/export", analyticsHandler.ExportProjectCSV)
Comment on lines +436 to +437

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve trailing slashes for project analytics routes.

  • apps/api/internal/router/router.go#L436-L437: register both project analytics endpoints with trailing /.
  • apps/web/src/pages/AnalyticsWorkItemsPage.tsx#L206-L208: call the corresponding slash-terminated export URL.

As per path instructions, “preserve intentional trailing slashes in API routes because the web app expects them.”

📍 Affects 2 files
  • apps/api/internal/router/router.go#L436-L437 (this comment)
  • apps/web/src/pages/AnalyticsWorkItemsPage.tsx#L206-L208
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/internal/router/router.go` around lines 436 - 437, Preserve trailing
slashes for both project analytics routes in apps/api/internal/router/router.go
at lines 436-437 by registering slash-terminated paths. Update the export
request in apps/web/src/pages/AnalyticsWorkItemsPage.tsx at lines 206-208 to use
the corresponding slash-terminated URL.

Source: Path instructions


api.GET("/workspaces/:slug/projects/:projectId/cycles/", cycleHandler.List)
api.GET("/workspaces/:slug/projects/:projectId/cycles-progress/", cycleHandler.CyclesProgress)
api.POST("/workspaces/:slug/projects/:projectId/cycles/", cycleHandler.Create)
Expand All @@ -438,8 +450,8 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) {
api.GET("/workspaces/:slug/projects/:projectId/cycles/:cycleId/cycle-progress/", cycleHandler.Progress)
api.GET("/workspaces/:slug/projects/:projectId/cycles/:cycleId/analytics", cycleHandler.Analytics)

api.GET("/workspaces/:slug/projects/:projectId/modules/", moduleHandler.List)
api.GET("/workspaces/:slug/projects/:projectId/modules-progress/", moduleHandler.ModulesProgress)
api.GET("/workspaces/:slug/projects/:projectId/modules/", moduleHandler.List)
api.POST("/workspaces/:slug/projects/:projectId/modules/", moduleHandler.Create)
api.GET("/workspaces/:slug/projects/:projectId/modules/:moduleId/", moduleHandler.Get)
api.PATCH("/workspaces/:slug/projects/:projectId/modules/:moduleId/", moduleHandler.Update)
Expand Down
Loading
Loading