Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
41e5ac3
better err handling again
LADsy8 Jul 24, 2026
c61acc9
change the exports to work when errors occurs
LADsy8 Jul 24, 2026
8932557
added trend data possibilities for projects and workspaces
LADsy8 Jul 24, 2026
cfc2781
returned errors like any other files, and added trends to the good re…
LADsy8 Jul 24, 2026
17a7e01
added numerous changes to correct coderabbit reported errors
LADsy8 Jul 24, 2026
db99f5f
now i think this commit corrects all issues from my code
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
225 changes: 225 additions & 0 deletions apps/api/internal/handler/analytics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
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/model"
"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
}

writer := csv.NewWriter(c.Writer)
headerWritten := false

err := h.AnalyticsService.ExportWorkspaceCSV(c.Request.Context(), slug, user.ID, func(issue model.WorkspaceIssueExport) error {
if !headerWritten {
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")

if err := writer.Write([]string{"Issue ID", "Title", "State", "Priority", "Assignee", "Labels"}); err != nil {
return err
}
headerWritten = true
}

if err := writer.Write([]string{
issue.ID,
sanitizeCSVField(issue.Name),
sanitizeCSVField(issue.State),
sanitizeCSVField(issue.Priority),
sanitizeCSVField(issue.Assignee),
sanitizeCSVField(issue.Labels),
}); err != nil {
return err
}

writer.Flush()
return writer.Error()
})

if err != nil {
if !headerWritten {
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
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch workspace data"})
}
h.Log.Error("failed to stream workspace CSV export", "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
}

writer := csv.NewWriter(c.Writer)
headerWritten := false

err := h.AnalyticsService.ExportProjectCSV(c.Request.Context(), projectID, user.ID, func(issue model.ProjectIssueExport) error {
if !headerWritten {
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")

if err := writer.Write([]string{"Project Issue ID", "Title", "State", "Priority", "Assignee", "Labels"}); err != nil {
return err
}
headerWritten = true
}

if err := writer.Write([]string{
issue.ID,
sanitizeCSVField(issue.Name),
sanitizeCSVField(issue.State),
sanitizeCSVField(issue.Priority),
sanitizeCSVField(issue.Assignee),
sanitizeCSVField(issue.Labels),
}); err != nil {
return err
}

writer.Flush()
return writer.Error()
})

if err != nil {
if !headerWritten {
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
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project data"})
}
h.Log.Error("failed to stream project CSV export", "error", err, "project_id", projectID)
}
}
45 changes: 45 additions & 0 deletions apps/api/internal/model/analytics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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 `json:"id" gorm:"column:id"`
Name string `json:"name" gorm:"column:name"`
State string `json:"state" gorm:"column:state"`
Priority string `json:"priority" gorm:"column:priority"`
Assignee string `json:"assignee" gorm:"column:assignee"`
Labels string `json:"labels" gorm:"column:labels"`
}

type ProjectIssueExport struct {
ID string `json:"id" gorm:"column:id"`
Name string `json:"name" gorm:"column:name"`
State string `json:"state" gorm:"column:state"`
Priority string `json:"priority" gorm:"column:priority"`
Assignee string `json:"assignee" gorm:"column:assignee"`
Labels string `json:"labels" gorm:"column:labels"`
}

type TrendPoint struct {
Date string `gorm:"column:date" json:"date"`
Created int64 `gorm:"column:created" json:"created"`
Resolved int64 `gorm:"column:resolved" json:"resolved"`
}
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