-
Notifications
You must be signed in to change notification settings - Fork 61
feat(csv): added the csv export to the analytics page #393
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
Closed
Closed
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
09b9c1b
test for me for my machine
LADsy8 2cb351a
feat: *NOT FINISHED* the projects wont load anymore, probably broke a…
LADsy8 b159108
feat: *NOT FINISHED* wanted to removed a test file
LADsy8 39ce79e
beginning to change the tsx
LADsy8 d343a7c
please let me have there changes
LADsy8 050e74c
Merge remote-tracking branch 'refs/remotes/origin/main'
LADsy8 5b0c442
beginning a branch
LADsy8 5d6b904
test
LADsy8 cada368
Feat: Repaired my project not found error
LADsy8 fe3ec38
Feat: *not finished* working export for priorities
LADsy8 50db926
Feat: finished the issue i think
LADsy8 112424c
Feat: added the response type of assignee and label, but there are no…
LADsy8 82c2818
removed the logs i had for testing
LADsy8 1a96f88
added a service for tonight but might have to recheck it with what i …
LADsy8 15c38da
pull request corrections
LADsy8 438b4d4
added analytics as a store
LADsy8 97edaf9
the store is missing,
LADsy8 68ae41d
Added the store and builded the model off of that
LADsy8 3427bae
Update apps/api/internal/handler/analytics.go
LADsy8 96cc86f
refactored the service to use the good errors
LADsy8 7902f85
Merge remote-tracking branch 'refs/remotes/origin/correctingPR' into …
LADsy8 3652190
followed the position of parameters from the service
LADsy8 a439186
Runned prettier
LADsy8 d3ee76a
joins issues and project tables and return error when getting
LADsy8 df95619
correcting handler with safeguards and sanitization
LADsy8 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,205 @@ | ||
| package handler | ||
|
|
||
| import ( | ||
| "encoding/csv" | ||
| "errors" | ||
| "fmt" | ||
| "log/slog" | ||
| "net/http" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/Devlaner/devlane/api/internal/middleware" | ||
| "github.com/Devlaner/devlane/api/internal/service" | ||
| "github.com/gin-gonic/gin" | ||
| "github.com/google/uuid" | ||
| ) | ||
|
|
||
| type AnalyticsHandler struct { | ||
| AnalyticsService *service.AnalyticsService | ||
| Log *slog.Logger | ||
| } | ||
|
|
||
| func sanitizeCSVField(v string) string { | ||
| if v == "" { | ||
| return v | ||
| } | ||
| switch v[0] { | ||
| case '=', '+', '-', '@', '\t', '\r': | ||
| return "'" + v | ||
| default: | ||
| return v | ||
| } | ||
| } | ||
|
|
||
| func sanitizeFilename(s string) string { | ||
| r := strings.NewReplacer("\"", "", "\n", "", "\r", "") | ||
| return r.Replace(s) | ||
| } | ||
|
|
||
| func parseParamUUID(c *gin.Context, key string) (uuid.UUID, bool) { | ||
| val := c.Param(key) | ||
| if val == "" { | ||
| val = c.Param("projectID") | ||
| } | ||
| if val == "" { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "missing ID parameter"}) | ||
| return uuid.Nil, false | ||
| } | ||
| parsed, err := uuid.Parse(val) | ||
| if err != nil { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) | ||
| return uuid.Nil, false | ||
| } | ||
| return parsed, true | ||
| } | ||
|
|
||
| func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) { | ||
| slug := c.Param("slug") | ||
| user := middleware.GetUser(c) | ||
| if user == nil { | ||
| c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) | ||
| return | ||
| } | ||
|
|
||
| res, err := h.AnalyticsService.GetWorkspaceAnalytics(c.Request.Context(), slug, user.ID) | ||
| if err != nil { | ||
| if errors.Is(err, service.ErrWorkspaceForbidden) { | ||
| c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) | ||
| return | ||
| } | ||
| if errors.Is(err, service.ErrWorkspaceNotFound) { | ||
| c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"}) | ||
| return | ||
| } | ||
| h.Log.Error("failed to fetch workspace analytics", "error", err, "slug", slug) | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) | ||
| return | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, res) | ||
| } | ||
|
|
||
| func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { | ||
| projectID, ok := parseParamUUID(c, "projectId") | ||
| if !ok { | ||
| return | ||
| } | ||
|
|
||
| user := middleware.GetUser(c) | ||
| if user == nil { | ||
| c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) | ||
| return | ||
| } | ||
|
|
||
| res, err := h.AnalyticsService.GetProjectAnalytics(c.Request.Context(), projectID, user.ID) | ||
| if err != nil { | ||
| if errors.Is(err, service.ErrProjectForbidden) || errors.Is(err, service.ErrWorkspaceForbidden) { | ||
| c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) | ||
| return | ||
| } | ||
| if errors.Is(err, service.ErrProjectNotFound) || errors.Is(err, service.ErrWorkspaceNotFound) { | ||
| c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) | ||
| return | ||
| } | ||
| h.Log.Error("failed to fetch project analytics", "error", err, "project_id", projectID) | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) | ||
| return | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, res) | ||
| } | ||
|
|
||
| func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { | ||
| slug := c.Param("slug") | ||
| user := middleware.GetUser(c) | ||
| if user == nil { | ||
| c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) | ||
| return | ||
| } | ||
|
|
||
| issues, err := h.AnalyticsService.ExportWorkspaceCSV(c.Request.Context(), slug, user.ID) | ||
| if err != nil { | ||
| if errors.Is(err, service.ErrWorkspaceForbidden) { | ||
| c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) | ||
| return | ||
| } | ||
| if errors.Is(err, service.ErrWorkspaceNotFound) { | ||
| c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"}) | ||
| return | ||
| } | ||
| h.Log.Error("failed to fetch workspace issues for CSV export", "error", err, "slug", slug) | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch workspace data"}) | ||
| return | ||
| } | ||
|
|
||
| safeSlug := sanitizeFilename(slug) | ||
| filename := fmt.Sprintf("workspace-%s-analytics-%s.csv", safeSlug, 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) | ||
| _ = writer.Write([]string{"Issue ID", "Title", "State", "Priority"}) | ||
|
|
||
| for _, issue := range issues { | ||
| _ = writer.Write([]string{ | ||
| issue.ID, | ||
| sanitizeCSVField(issue.Name), | ||
| sanitizeCSVField(issue.State), | ||
| sanitizeCSVField(issue.Priority), | ||
| }) | ||
| } | ||
|
|
||
| writer.Flush() | ||
| if err := writer.Error(); err != nil { | ||
| h.Log.Error("failed to flush CSV writer", "error", err, "slug", slug) | ||
| } | ||
| } | ||
|
|
||
| func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { | ||
| projectID, ok := parseParamUUID(c, "projectId") | ||
| if !ok { | ||
| return | ||
| } | ||
|
|
||
| user := middleware.GetUser(c) | ||
| if user == nil { | ||
| c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) | ||
| return | ||
| } | ||
|
|
||
| issues, err := h.AnalyticsService.ExportProjectCSV(c.Request.Context(), projectID, user.ID) | ||
| if err != nil { | ||
| if errors.Is(err, service.ErrProjectForbidden) || errors.Is(err, service.ErrWorkspaceForbidden) { | ||
| c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) | ||
| return | ||
| } | ||
| if errors.Is(err, service.ErrProjectNotFound) || errors.Is(err, service.ErrWorkspaceNotFound) { | ||
| c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) | ||
| return | ||
| } | ||
| h.Log.Error("failed to fetch project issues for CSV export", "error", err, "project_id", projectID) | ||
| 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) | ||
| _ = writer.Write([]string{"Project Issue ID", "Title", "State"}) | ||
|
|
||
| for _, issue := range issues { | ||
| _ = writer.Write([]string{ | ||
| issue.ID, | ||
| sanitizeCSVField(issue.Name), | ||
| sanitizeCSVField(issue.State), | ||
| }) | ||
| } | ||
|
|
||
| writer.Flush() | ||
| if err := writer.Error(); err != nil { | ||
| h.Log.Error("failed to flush CSV writer", "error", err, "project_id", projectID) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| package model | ||
|
|
||
| type StateCount struct { | ||
| State string `gorm:"column:state" json:"state"` | ||
| Count int64 `gorm:"column:count" json:"count"` | ||
| } | ||
|
|
||
| type PriorityCount struct { | ||
| Priority string `gorm:"column:priority" json:"priority"` | ||
| Count int64 `gorm:"column:count" json:"count"` | ||
| } | ||
|
|
||
| type AssigneeCount struct { | ||
| Email string `gorm:"column:email" json:"email"` | ||
| Count int64 `gorm:"column:count" json:"count"` | ||
| } | ||
|
|
||
| type LabelCount struct { | ||
| Label string `gorm:"column:label" json:"label"` | ||
| Count int64 `gorm:"column:count" json:"count"` | ||
| } | ||
|
|
||
| type WorkspaceIssueExport struct { | ||
| ID string `gorm:"column:id" json:"id"` | ||
| Name string `gorm:"column:name" json:"name"` | ||
| State string `gorm:"column:state" json:"state"` | ||
| Priority string `gorm:"column:priority" json:"priority"` | ||
| } | ||
|
|
||
| type ProjectIssueExport struct { | ||
| ID string `gorm:"column:id" json:"id"` | ||
| Name string `gorm:"column:name" json:"name"` | ||
| State string `gorm:"column:state" json:"state"` | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.