-
Notifications
You must be signed in to change notification settings - Fork 57
Feat(csv): adding csv exports that are server side #313
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
09b9c1b
2cb351a
b159108
39ce79e
d343a7c
050e74c
5b0c442
5d6b904
cada368
fe3ec38
50db926
112424c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
📍 Affects 2 files
🤖 Prompt for AI AgentsSource: 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Also applies to: 300-307 🤖 Prompt for AI Agents |
||
| } | ||
|
Comment on lines
+254
to
+266
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Check CSV write and flush errors. Every 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 |
||
| } | ||
|
|
||
| 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, | ||
| }) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Preserve trailing slashes for project analytics routes.
As per path instructions, “preserve intentional trailing slashes in API routes because the web app expects them.” 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: 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) | ||
|
|
@@ -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) | ||
|
|
||
There was a problem hiding this comment.
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
Source: Coding guidelines