Skip to content
Closed
Show file tree
Hide file tree
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 Jul 8, 2026
2cb351a
feat: *NOT FINISHED* the projects wont load anymore, probably broke a…
LADsy8 Jul 8, 2026
b159108
feat: *NOT FINISHED* wanted to removed a test file
LADsy8 Jul 8, 2026
39ce79e
beginning to change the tsx
LADsy8 Jul 12, 2026
d343a7c
please let me have there changes
LADsy8 Jul 12, 2026
050e74c
Merge remote-tracking branch 'refs/remotes/origin/main'
LADsy8 Jul 14, 2026
5b0c442
beginning a branch
LADsy8 Jul 14, 2026
5d6b904
test
LADsy8 Jul 14, 2026
cada368
Feat: Repaired my project not found error
LADsy8 Jul 14, 2026
fe3ec38
Feat: *not finished* working export for priorities
LADsy8 Jul 14, 2026
50db926
Feat: finished the issue i think
LADsy8 Jul 14, 2026
112424c
Feat: added the response type of assignee and label, but there are no…
LADsy8 Jul 14, 2026
82c2818
removed the logs i had for testing
LADsy8 Jul 17, 2026
1a96f88
added a service for tonight but might have to recheck it with what i …
LADsy8 Jul 17, 2026
15c38da
pull request corrections
LADsy8 Jul 22, 2026
438b4d4
added analytics as a store
LADsy8 Jul 24, 2026
97edaf9
the store is missing,
LADsy8 Jul 24, 2026
68ae41d
Added the store and builded the model off of that
LADsy8 Jul 24, 2026
3427bae
Update apps/api/internal/handler/analytics.go
LADsy8 Jul 24, 2026
96cc86f
refactored the service to use the good errors
LADsy8 Jul 24, 2026
7902f85
Merge remote-tracking branch 'refs/remotes/origin/correctingPR' into …
LADsy8 Jul 24, 2026
3652190
followed the position of parameters from the service
LADsy8 Jul 24, 2026
a439186
Runned prettier
LADsy8 Jul 24, 2026
d3ee76a
joins issues and project tables and return error when getting
LADsy8 Jul 24, 2026
df95619
correcting handler with safeguards and sanitization
LADsy8 Jul 24, 2026
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
205 changes: 205 additions & 0 deletions apps/api/internal/handler/analytics.go
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)
}
}
Comment thread
LADsy8 marked this conversation as resolved.

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)
}
}
34 changes: 34 additions & 0 deletions apps/api/internal/model/analytics.go
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"`
}
16 changes: 15 additions & 1 deletion apps/api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,14 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) {
Queue: cfg.Queue,
AppBaseURL: appBaseURL,
}

analyticsStore := store.NewAnalyticsStore(cfg.DB)
analyticsSvc := service.NewAnalyticsService(analyticsStore, workspaceStore, projectStore, cfg.Log)
analyticsHandler := &handler.AnalyticsHandler{
AnalyticsService: analyticsSvc,
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 +432,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)

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 +452,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