From 09b9c1b3d9b3d482acedf47955bb111cc8ff0d07 Mon Sep 17 00:00:00 2001 From: LADsy8 Date: Tue, 7 Jul 2026 22:15:18 -0400 Subject: [PATCH 01/28] test for me for my machine --- hihi.tsx | 1 + 1 file changed, 1 insertion(+) create mode 100644 hihi.tsx diff --git a/hihi.tsx b/hihi.tsx new file mode 100644 index 0000000..7c96369 --- /dev/null +++ b/hihi.tsx @@ -0,0 +1 @@ +dunsadnsandjsandjsandjasnj: wq; From 2cb351a7a03fc8a09ec1c6b6116530a0677b3232 Mon Sep 17 00:00:00 2001 From: LADsy8 Date: Tue, 7 Jul 2026 22:25:31 -0400 Subject: [PATCH 02/28] feat: *NOT FINISHED* the projects wont load anymore, probably broke a line in one of the project GET --- apps/api/internal/handler/analytics.go | 99 ++++++++++++++ apps/api/internal/router/router.go | 14 +- apps/web/src/pages/AnalyticsWorkItemsPage.tsx | 126 +++++++++--------- 3 files changed, 177 insertions(+), 62 deletions(-) create mode 100644 apps/api/internal/handler/analytics.go diff --git a/apps/api/internal/handler/analytics.go b/apps/api/internal/handler/analytics.go new file mode 100644 index 0000000..449e6f4 --- /dev/null +++ b/apps/api/internal/handler/analytics.go @@ -0,0 +1,99 @@ +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"` +} + +func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) { + slug := c.Param("slug") + + var stateResults []struct { + State string + Count int64 + } + if err := h.DB.Table("issues").Select("state, count(*) as count").Where("workspace_slug = ?", slug).Group("state").Scan(&stateResults).Error; 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 + } + + c.JSON(http.StatusOK, AnalyticsResponse{ + ByState: byState, + ByPriority: map[string]int64{"high": 3, "medium": 8, "low": 12}, // Exemple statique à lier à votre DB + }) +} + +func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { + projectID := c.Param("projectID") + + var stateResults []struct { + State string + Count int64 + } + if err := h.DB.Table("issues").Select("state, count(*) as count").Where("project_id = ?", projectID).Group("state").Scan(&stateResults).Error; 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 + } + + c.JSON(http.StatusOK, AnalyticsResponse{ByState: byState}) +} + +func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { + slug := c.Param("slug") + 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"}) + + _ = writer.Write([]string{"ISSUE-1", "Fix login bug", "In Progress", "High"}) + _ = writer.Write([]string{"ISSUE-2", "Setup Docker environment", "Done", "Medium"}) +} + +func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { + projectID := c.Param("projectID") + 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"}) + _ = writer.Write([]string{"PROJ-101", "Implement analytics endpoints", "In Progress"}) +} diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 7ceca58..70b014f 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -221,6 +221,12 @@ func New(cfg Config) *gin.Engine { Queue: cfg.Queue, AppBaseURL: appBaseURL, } + + analyticsHandler := &handler.AnalyticsHandler{ + DB: cfg.DB, + Log: cfg.Log, + } + projectHandler := &handler.ProjectHandler{Project: projectSvc, State: stateSvc} favoriteHandler := &handler.FavoriteHandler{Project: projectSvc, Favorites: userFavoriteStore} stateHandler := &handler.StateHandler{State: stateSvc} @@ -360,6 +366,12 @@ func New(cfg Config) *gin.Engine { 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.POST("/workspaces/:slug/projects/:projectId/cycles/", cycleHandler.Create) api.GET("/workspaces/:slug/projects/:projectId/cycles/:cycleId/", cycleHandler.Get) @@ -372,9 +384,7 @@ func New(cfg Config) *gin.Engine { 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.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) api.DELETE("/workspaces/:slug/projects/:projectId/modules/:moduleId/", moduleHandler.Delete) api.GET("/workspaces/:slug/projects/:projectId/modules/:moduleId/issues/", moduleHandler.ListIssues) diff --git a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx index f5ef51f..7c753c1 100644 --- a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx +++ b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; import { LineChart, Line, @@ -10,18 +10,18 @@ import { ResponsiveContainer, BarChart, Bar, -} from 'recharts'; -import { workspaceService } from '../services/workspaceService'; -import { projectService } from '../services/projectService'; -import { issueService } from '../services/issueService'; -import { stateService } from '../services/stateService'; -import { useDocumentTitle } from '../hooks/useDocumentTitle'; +} from "recharts"; +import { workspaceService } from "../services/workspaceService"; +import { projectService } from "../services/projectService"; +import { issueService } from "../services/issueService"; +import { stateService } from "../services/stateService"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; import type { WorkspaceApiResponse, ProjectApiResponse, IssueApiResponse, StateApiResponse, -} from '../api/types'; +} from "../api/types"; const IconSearch = () => ( ([]); const [loading, setLoading] = useState(true); - useDocumentTitle('Analytics'); + useDocumentTitle("Analytics"); useEffect(() => { if (!workspaceSlug) { @@ -153,17 +153,17 @@ export function AnalyticsWorkItemsPage() { }, [workspaceSlug]); const getStateName = (stateId: string | null | undefined) => - stateId ? (states.find((s) => s.id === stateId)?.name ?? stateId) : '—'; + stateId ? (states.find((s) => s.id === stateId)?.name ?? stateId) : "—"; - const backlogCount = issues.filter((i) => getStateName(i.state_id) === 'Backlog').length; - const startedCount = issues.filter((i) => getStateName(i.state_id) === 'In Progress').length; - const unstartedCount = issues.filter((i) => getStateName(i.state_id) === 'Todo').length; - const completedCount = issues.filter((i) => getStateName(i.state_id) === 'Done').length; + const backlogCount = issues.filter((i) => getStateName(i.state_id) === "Backlog").length; + const startedCount = issues.filter((i) => getStateName(i.state_id) === "In Progress").length; + const unstartedCount = issues.filter((i) => getStateName(i.state_id) === "Todo").length; + const completedCount = issues.filter((i) => getStateName(i.state_id) === "Done").length; const priorityCounts = issues.reduce>((acc, i) => { const p = - !i.priority || i.priority === 'none' - ? 'None' + !i.priority || i.priority === "none" + ? "None" : i.priority.charAt(0).toUpperCase() + i.priority.slice(1); acc[p] = (acc[p] ?? 0) + 1; return acc; @@ -173,7 +173,7 @@ export function AnalyticsWorkItemsPage() { count, })); - const doneStateIds = new Set(states.filter((s) => s.name === 'Done').map((s) => s.id)); + const doneStateIds = new Set(states.filter((s) => s.name === "Done").map((s) => s.id)); const createdByDate = issues.reduce>((acc, i) => { const d = i.created_at.slice(0, 10); acc[d] = (acc[d] ?? 0) + 1; @@ -192,11 +192,11 @@ export function AnalyticsWorkItemsPage() { const createdResolvedData = allDates.length > 0 ? allDates.map((dateStr) => { - const d = new Date(dateStr + 'T12:00:00Z'); - const label = d.toLocaleDateString('en-US', { - month: 'short', - day: '2-digit', - year: 'numeric', + const d = new Date(dateStr + "T12:00:00Z"); + const label = d.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", }); return { date: label, @@ -207,10 +207,10 @@ export function AnalyticsWorkItemsPage() { }) : [ { - date: new Date().toLocaleDateString('en-US', { - month: 'short', - day: '2-digit', - year: 'numeric', + date: new Date().toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", }), dateKey: new Date().toISOString().slice(0, 10), created: 0, @@ -222,10 +222,10 @@ export function AnalyticsWorkItemsPage() { const projIssues = issues.filter((i) => i.project_id === p.id); return { project: p, - backlog: projIssues.filter((i) => getStateName(i.state_id) === 'Backlog').length, - started: projIssues.filter((i) => getStateName(i.state_id) === 'In Progress').length, - unstarted: projIssues.filter((i) => getStateName(i.state_id) === 'Todo').length, - completed: projIssues.filter((i) => getStateName(i.state_id) === 'Done').length, + backlog: projIssues.filter((i) => getStateName(i.state_id) === "Backlog").length, + started: projIssues.filter((i) => getStateName(i.state_id) === "In Progress").length, + unstarted: projIssues.filter((i) => getStateName(i.state_id) === "Todo").length, + completed: projIssues.filter((i) => getStateName(i.state_id) === "Done").length, cancelled: 0, }; }); @@ -304,29 +304,29 @@ export function AnalyticsWorkItemsPage() { /> @@ -401,29 +401,29 @@ export function AnalyticsWorkItemsPage() { /> @@ -439,7 +439,7 @@ export function AnalyticsWorkItemsPage() {

{priorityRows.length} Priority - {priorityRows.length !== 1 ? 'ies' : ''} + {priorityRows.length !== 1 ? "ies" : ""}

@@ -447,6 +447,9 @@ export function AnalyticsWorkItemsPage() {
- - - - - + - {projectRows.map(({ project, backlog, started, unstarted, completed, cancelled }) => ( + {projects.map((project) => ( - - - - - + ))} @@ -532,4 +440,4 @@ export function AnalyticsWorkItemsPage() { ); -} +} \ No newline at end of file From 5b0c44267f5acd17a85d74c71a10898c52f7d6ed Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 14 Jul 2026 13:00:56 -0400 Subject: [PATCH 05/28] beginning a branch --- apps/web/src/pages/AnalyticsWorkItemsPage.tsx | 153 +----------------- 1 file changed, 1 insertion(+), 152 deletions(-) diff --git a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx index 62efe20..b01e0ca 100644 --- a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx +++ b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx @@ -1,11 +1,5 @@ -<<<<<<< HEAD import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; -======= -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Link, useParams } from 'react-router-dom'; ->>>>>>> refs/remotes/origin/main import { LineChart, Line, @@ -26,7 +20,7 @@ interface AnalyticsResponse { by_state: Record; by_priority: Record; } - +6 const IconSearch = () => ( (null); const [loading, setLoading] = useState(true); -<<<<<<< HEAD useDocumentTitle("Analytics"); -======= - useDocumentTitle(t('analytics.documentTitle', 'Analytics')); ->>>>>>> refs/remotes/origin/main useEffect(() => { if (!workspaceSlug) { @@ -130,64 +120,9 @@ export function AnalyticsWorkItemsPage() { if (cancelled) return; setWorkspace(w); -<<<<<<< HEAD projectService.list(workspaceSlug) .then((projs) => { if (!cancelled && projs) setProjects(projs); -======= - const getStateName = (stateId: string | null | undefined) => - stateId ? (states.find((s) => s.id === stateId)?.name ?? stateId) : '—'; - - const backlogCount = issues.filter((i) => getStateName(i.state_id) === 'Backlog').length; - const startedCount = issues.filter((i) => getStateName(i.state_id) === 'In Progress').length; - const unstartedCount = issues.filter((i) => getStateName(i.state_id) === 'Todo').length; - const completedCount = issues.filter((i) => getStateName(i.state_id) === 'Done').length; - - const priorityCounts = issues.reduce>((acc, i) => { - const p = - !i.priority || i.priority === 'none' - ? t('common.none', 'None') - : i.priority.charAt(0).toUpperCase() + i.priority.slice(1); - acc[p] = (acc[p] ?? 0) + 1; - return acc; - }, {}); - const priorityRows = Object.entries(priorityCounts).map(([priority, count]) => ({ - priority, - count, - })); - - const doneStateIds = new Set(states.filter((s) => s.name === 'Done').map((s) => s.id)); - const createdByDate = issues.reduce>((acc, i) => { - const d = i.created_at.slice(0, 10); - acc[d] = (acc[d] ?? 0) + 1; - return acc; - }, {}); - const resolvedByDate = issues - .filter((i) => i.state_id && doneStateIds.has(i.state_id)) - .reduce>((acc, i) => { - const d = i.updated_at.slice(0, 10); - acc[d] = (acc[d] ?? 0) + 1; - return acc; - }, {}); - const allDates = Array.from( - new Set([...Object.keys(createdByDate), ...Object.keys(resolvedByDate)]), - ).sort(); - const createdResolvedData = - allDates.length > 0 - ? allDates.map((dateStr) => { - const d = new Date(dateStr + 'T12:00:00Z'); - const label = d.toLocaleDateString('en-US', { - month: 'short', - day: '2-digit', - year: 'numeric', - }); - return { - date: label, - dateKey: dateStr, - created: createdByDate[dateStr] ?? 0, - resolved: resolvedByDate[dateStr] ?? 0, - }; ->>>>>>> refs/remotes/origin/main }) .catch((err) => console.error("Erreur projets:", err)); @@ -254,25 +189,11 @@ export function AnalyticsWorkItemsPage() {
{/* Tabs */}
-<<<<<<< HEAD Overview Work items -======= - - {t('analytics.overview', 'Overview')} - - - {t('analytics.workItems', 'Work items')} ->>>>>>> refs/remotes/origin/main
@@ -283,15 +204,8 @@ export function AnalyticsWorkItemsPage() { {/* KPIs */}
-<<<<<<< HEAD

Total Work items

{totalIssues}

-======= -

- {t('analytics.totalWorkItems', 'Total Work items')} -

-

{issues.length}

->>>>>>> refs/remotes/origin/main

@@ -342,13 +256,8 @@ export function AnalyticsWorkItemsPage() { tickLine={{ stroke: "var(--border-subtle)" }} axisLine={{ stroke: "var(--border-subtle)" }} label={{ -<<<<<<< HEAD value: "DATE", position: "insideBottom", -======= - value: t('analytics.axisDate', 'DATE'), - position: 'insideBottom', ->>>>>>> refs/remotes/origin/main offset: -4, fill: "var(--txt-tertiary)", fontSize: 11, @@ -359,11 +268,7 @@ export function AnalyticsWorkItemsPage() { tickLine={{ stroke: "var(--border-subtle)" }} axisLine={{ stroke: "var(--border-subtle)" }} label={{ -<<<<<<< HEAD value: "NO. OF WORK ITEMS", -======= - value: t('analytics.axisNoOfWorkItems', 'NO. OF WORK ITEMS'), ->>>>>>> refs/remotes/origin/main angle: -90, position: "insideLeft", fill: "var(--txt-tertiary)", @@ -451,13 +356,8 @@ export function AnalyticsWorkItemsPage() { tickLine={{ stroke: "var(--border-subtle)" }} axisLine={{ stroke: "var(--border-subtle)" }} label={{ -<<<<<<< HEAD value: "PRIORITY", position: "insideBottom", -======= - value: t('analytics.axisPriority', 'PRIORITY'), - position: 'insideBottom', ->>>>>>> refs/remotes/origin/main offset: -4, fill: "var(--txt-tertiary)", fontSize: 11, @@ -468,11 +368,7 @@ export function AnalyticsWorkItemsPage() { tickLine={{ stroke: "var(--border-subtle)" }} axisLine={{ stroke: "var(--border-subtle)" }} label={{ -<<<<<<< HEAD value: "NO. OF WORK ITEM", -======= - value: t('analytics.axisNoOfWorkItem', 'NO. OF WORK ITEM'), ->>>>>>> refs/remotes/origin/main angle: -90, position: "insideLeft", fill: "var(--txt-tertiary)", @@ -492,17 +388,7 @@ export function AnalyticsWorkItemsPage() {

-<<<<<<< HEAD

{priorityRows.length} Priorities

-======= -

- {priorityRows.length} {t('analytics.priority', 'Priority')} - {priorityRows.length !== 1 ? t('analytics.prioritySuffixPlural', 'ies') : ''} -

- - - ->>>>>>> refs/remotes/origin/main
->>>>>>> refs/remotes/origin/main
ProjectBacklogStartedUnstartedCompletedCancelledActions
- - - + {project.name}
{backlog}{started}{unstarted}{completed}{cancelled} + {/* Le bouton export possède désormais le project.id valide grâce à la boucle map */} + +
-<<<<<<< HEAD -======= - - - - - - ->>>>>>> refs/remotes/origin/main From 5d6b90412046b87ceca2ee76f61620adb83fb01a Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 14 Jul 2026 13:00:58 -0400 Subject: [PATCH 06/28] test --- apps/web/package-lock.json | 577 ++++++++++++++++--------------------- 1 file changed, 255 insertions(+), 322 deletions(-) diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index d74cf4a..44a6d89 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -95,7 +95,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -315,13 +314,12 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], - "license": "MIT", "optional": true, "os": [ "aix" @@ -331,13 +329,12 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], - "license": "MIT", "optional": true, "os": [ "android" @@ -347,13 +344,12 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "android" @@ -363,13 +359,12 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "android" @@ -379,13 +374,12 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -395,13 +389,12 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -411,13 +404,12 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -427,13 +419,12 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -443,13 +434,12 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -459,13 +449,12 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -475,13 +464,12 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -491,13 +479,12 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -507,13 +494,12 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -523,13 +509,12 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -539,13 +524,12 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -555,13 +539,12 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -571,13 +554,12 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -587,13 +569,12 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -603,13 +584,12 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -619,13 +599,12 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -635,13 +614,12 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -651,13 +629,12 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "openharmony" @@ -667,13 +644,12 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "sunos" @@ -683,13 +659,12 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -699,13 +674,12 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -715,13 +689,12 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -901,7 +874,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" @@ -2161,7 +2133,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.3.tgz", "integrity": "sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2423,7 +2394,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.3.tgz", "integrity": "sha512-rqvv/dtqwbX+8KnPv0eMYp6PnBcuhPMol5cv1GlS8Nq/Cxt68EWGUHBuTFesw+hdnRQLmKwzoO1DlRn7PhxYRQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2531,7 +2501,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.3.tgz", "integrity": "sha512-inbQSusJad7H0T++L1APg/anfL5d15cNGp2YG3vwo6TQr71nn2c9pepvmz3xuAIt8eygZDRba+4GT/COP1f9QA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2637,7 +2606,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.3.tgz", "integrity": "sha512-JKmWAogM/LX9ZJmXJQalpcR77wWVtVXdRFgvHGsFomW9WFhZqcnIEDWR2sbpZHWtu8dml6eBQGhdLppJmxeFfA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2664,7 +2632,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.3.tgz", "integrity": "sha512-s5eiMq0m5N6N+W7dU6rd60KgZyyCD7FvtPNNswISfPr12EQwJBfbjWwTqd0UKNzA4fNrhQEERXnzORkykttPeA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2679,7 +2646,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.3.tgz", "integrity": "sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -2773,7 +2739,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.22.3.tgz", "integrity": "sha512-m2c+5gDj2vW7UI1J4JHCKehQUVE12qBhgF+DC+WEWUU8ZrFNf5OEYWQHDNsopa5RRpilfKfhPNbMtXgvGOsk6g==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2894,7 +2859,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2904,7 +2868,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2914,7 +2877,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2984,7 +2946,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -3232,7 +3193,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3568,7 +3528,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -4257,11 +4216,10 @@ ] }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -4269,32 +4227,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -4325,7 +4283,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5141,7 +5098,6 @@ } ], "license": "MIT", - "peer": true, "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, @@ -5161,49 +5117,47 @@ } }, "node_modules/i18next-parser": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/i18next-parser/-/i18next-parser-9.0.2.tgz", - "integrity": "sha512-Q1yTZljBp1DcVAQD7LxduEqFRpjIeZc+5VnQ+gU8qG9WvY3U5rqK0IVONRWNtngh3orb197bfy1Sz4wlwcplxg==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/i18next-parser/-/i18next-parser-9.4.0.tgz", + "integrity": "sha512-SLQJGDj/baBIB9ALmJVXSOXWh3Zn9+wH7J2IuQ4rvx8yuQYpUWitmt8cHFjj6FExjgr8dHfd1SGeQgkowXDO1Q==", "deprecated": "Project is deprecated, use i18next-cli instead", "dev": true, - "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2", + "@babel/runtime": "^7.25.0", "broccoli-plugin": "^4.0.7", "cheerio": "^1.0.0", - "colors": "1.4.0", - "commander": "~12.1.0", + "colors": "^1.4.0", + "commander": "^12.1.0", "eol": "^0.9.1", - "esbuild": "^0.23.0", - "fs-extra": "^11.1.0", + "esbuild": "^0.25.0", + "fs-extra": "^11.2.0", "gulp-sort": "^2.0.0", - "i18next": "^23.5.1", - "js-yaml": "4.1.0", - "lilconfig": "^3.0.0", - "rsvp": "^4.8.2", + "i18next": "^23.5.1 || ^24.2.0", + "js-yaml": "^4.1.0", + "lilconfig": "^3.1.3", + "rsvp": "^4.8.5", "sort-keys": "^5.0.0", "typescript": "^5.0.4", - "vinyl": "~3.0.0", + "vinyl": "^3.0.0", "vinyl-fs": "^4.0.0" }, "bin": { "i18next": "bin/cli.js" }, "engines": { - "node": ">=18.0.0 || >=20.0.0 || >=22.0.0", + "node": "^18.0.0 || ^20.0.0 || ^22.0.0", "npm": ">=6", "yarn": ">=1" } }, "node_modules/i18next-parser/node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "aix" @@ -5213,14 +5167,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -5230,14 +5183,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -5247,14 +5199,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -5264,14 +5215,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -5281,14 +5231,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -5298,14 +5247,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -5315,14 +5263,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -5332,14 +5279,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -5349,14 +5295,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -5366,14 +5311,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -5383,14 +5327,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -5400,14 +5343,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -5417,14 +5359,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -5434,14 +5375,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -5451,14 +5391,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -5468,14 +5407,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/linux-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", - "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -5484,15 +5422,30 @@ "node": ">=18" } }, + "node_modules/i18next-parser/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/i18next-parser/node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -5502,14 +5455,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -5519,14 +5471,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -5535,15 +5486,30 @@ "node": ">=18" } }, + "node_modules/i18next-parser/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/i18next-parser/node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "sunos" @@ -5553,14 +5519,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -5570,14 +5535,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -5587,14 +5551,13 @@ } }, "node_modules/i18next-parser/node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -5604,12 +5567,11 @@ } }, "node_modules/i18next-parser/node_modules/esbuild": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", - "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -5617,30 +5579,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.1", - "@esbuild/android-arm": "0.23.1", - "@esbuild/android-arm64": "0.23.1", - "@esbuild/android-x64": "0.23.1", - "@esbuild/darwin-arm64": "0.23.1", - "@esbuild/darwin-x64": "0.23.1", - "@esbuild/freebsd-arm64": "0.23.1", - "@esbuild/freebsd-x64": "0.23.1", - "@esbuild/linux-arm": "0.23.1", - "@esbuild/linux-arm64": "0.23.1", - "@esbuild/linux-ia32": "0.23.1", - "@esbuild/linux-loong64": "0.23.1", - "@esbuild/linux-mips64el": "0.23.1", - "@esbuild/linux-ppc64": "0.23.1", - "@esbuild/linux-riscv64": "0.23.1", - "@esbuild/linux-s390x": "0.23.1", - "@esbuild/linux-x64": "0.23.1", - "@esbuild/netbsd-x64": "0.23.1", - "@esbuild/openbsd-arm64": "0.23.1", - "@esbuild/openbsd-x64": "0.23.1", - "@esbuild/sunos-x64": "0.23.1", - "@esbuild/win32-arm64": "0.23.1", - "@esbuild/win32-ia32": "0.23.1", - "@esbuild/win32-x64": "0.23.1" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/i18next-parser/node_modules/i18next": { @@ -5667,19 +5631,6 @@ "@babel/runtime": "^7.23.2" } }, - "node_modules/i18next-parser/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6785,7 +6736,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6976,7 +6926,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -7006,7 +6955,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -7055,7 +7003,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -7171,7 +7118,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7181,7 +7127,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7228,7 +7173,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -7335,8 +7279,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -7903,7 +7846,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8147,17 +8089,9 @@ } }, "node_modules/vite": { -<<<<<<< HEAD "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", -======= - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", - "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", - "license": "MIT", - "peer": true, ->>>>>>> refs/remotes/origin/main "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -8446,7 +8380,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From cada36886cc51dba157369c267678491235a236b Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 14 Jul 2026 14:13:33 -0400 Subject: [PATCH 07/28] Feat: Repaired my project not found error --- apps/api/internal/router/router.go | 2 +- apps/web/src/pages/AnalyticsWorkItemsPage.tsx | 7 ++- apps/web/src/pages/IssueListPage.tsx | 57 +++++++++++-------- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index e977ee9..14aa6dc 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -6,7 +6,7 @@ import ( "time" "github.com/Devlaner/devlane/api/internal/auth" - "github.com/Devlaner/devlane/api/internal/github" + gh "github.com/Devlaner/devlane/api/internal/github" "github.com/Devlaner/devlane/api/internal/handler" "github.com/Devlaner/devlane/api/internal/middleware" "github.com/Devlaner/devlane/api/internal/minio" diff --git a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx index b01e0ca..953ad9f 100644 --- a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx +++ b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from 'react-i18next'; import { Link, useParams } from "react-router-dom"; import { LineChart, @@ -108,12 +109,12 @@ export function AnalyticsWorkItemsPage() { useEffect(() => { if (!workspaceSlug) { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug (kept for future use) + setLoading(false); return; } let cancelled = false; setLoading(true); - workspaceService .getBySlug(workspaceSlug) .then((w) => { @@ -127,7 +128,9 @@ export function AnalyticsWorkItemsPage() { .catch((err) => console.error("Erreur projets:", err)); fetch(`/api/workspaces/${workspaceSlug}/analytics`) + .then((res) => { + console.log("after fetch") if (!res.ok) { throw new Error(`Code erreur serveur Go : ${res.status}`); } @@ -392,7 +395,7 @@ export function AnalyticsWorkItemsPage() {
Project Actions - {t('analytics.colProject', 'Project')} - - {t('analytics.colBacklog', 'Backlog')} - - {t('analytics.colStarted', 'Started')} - - {t('analytics.colUnstarted', 'Unstarted')} - - {t('analytics.colCompleted', 'Completed')} - - {t('analytics.colCancelled', 'Cancelled')} -
- - @@ -416,8 +481,8 @@ export function AnalyticsWorkItemsPage() { {priorityRows.map(({ priority, count }) => ( - - + + ))} @@ -436,27 +501,30 @@ export function AnalyticsWorkItemsPage() {
+ {t('analytics.priority', 'Priority')} + {t('analytics.count', 'Count')}
{priority}{count}{priority}{count}
- - + + {projects.map((project) => ( - - From 50db92656a2dde31edb0c389f4defc97330c91e6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 14 Jul 2026 16:07:41 -0400 Subject: [PATCH 09/28] Feat: finished the issue i think --- apps/web/src/pages/AnalyticsWorkItemsPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx index 1243027..333da89 100644 --- a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx +++ b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx @@ -239,9 +239,9 @@ export function AnalyticsWorkItemsPage() { const totalIssues = backlogCount + startedCount + unstartedCount + completedCount; const priorityRows = Object.entries(analytics?.by_priority ?? {}).map(([priority, count]) => ({ - priority: priority.charAt(0).toUpperCase() + priority.slice(1), - count, - })); + priority: priority ? priority.charAt(0).toUpperCase() + priority.slice(1) : "None", + count, +})); const createdResolvedData: any[] = []; From 112424c76630272b2e232c21fe1b3a6a1263bfe9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 14 Jul 2026 16:51:13 -0400 Subject: [PATCH 10/28] Feat: added the response type of assignee and label, but there are not present in the database.. :( --- apps/api/internal/handler/analytics.go | 168 +++++++++++++++++++++---- 1 file changed, 143 insertions(+), 25 deletions(-) diff --git a/apps/api/internal/handler/analytics.go b/apps/api/internal/handler/analytics.go index edb371e..a1c64cc 100644 --- a/apps/api/internal/handler/analytics.go +++ b/apps/api/internal/handler/analytics.go @@ -19,21 +19,23 @@ type AnalyticsHandler struct { 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"` } 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", slug). + 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 @@ -48,6 +50,7 @@ func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) { byState[r.State] = r.Count } + // 2. Counts by Priority var priorityResults []struct { Priority string Count int64 @@ -70,24 +73,75 @@ func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) { 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) + } + c.JSON(http.StatusOK, AnalyticsResponse{ ByState: byState, ByPriority: byPriority, + ByAssignee: byAssignee, + ByLabel: byLabel, }) } func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { - projectID := c.Param("projectID") + 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", projectID). + Where("issues.project_id = ? AND issues.deleted_at IS NULL AND states.deleted_at IS NULL", projectID). Group("states.name"). Scan(&stateResults).Error @@ -102,39 +156,101 @@ func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { byState[r.State] = r.Count } - c.JSON(http.StatusOK, AnalyticsResponse{ByState: byState}) + // 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") - 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") var issues []struct { ID string - Name string // CHANGED: Changed Title to Name to match schema column 'name' + Name string State string Priority string } - // CHANGED: Querying issues.name instead of issues.title 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", slug). + 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 generate CSV"}) + 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() @@ -151,31 +267,33 @@ func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { } func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { - projectID := c.Param("projectID") - 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") + projectID := c.Param("projectId") + if projectID == "" { + projectID = c.Param("projectID") + } var issues []struct { ID string - Name string // CHANGED: Changed Title to Name to match schema column 'name' + Name string State string } - // CHANGED: Querying issues.name instead of issues.title 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", projectID). + 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 generate CSV"}) + 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() From 82c2818c2cc7a0b0be5d9f20d9367b817ca20d7c Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Jul 2026 22:37:21 -0400 Subject: [PATCH 11/28] removed the logs i had for testing --- apps/web/src/pages/AnalyticsWorkItemsPage.tsx | 230 +++++++++--------- apps/web/src/pages/IssueListPage.tsx | 26 +- 2 files changed, 128 insertions(+), 128 deletions(-) diff --git a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx index 333da89..0cad422 100644 --- a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx +++ b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Link, useParams } from "react-router-dom"; +import { Link, useParams } from 'react-router-dom'; import { LineChart, Line, @@ -11,12 +11,12 @@ import { ResponsiveContainer, BarChart, Bar, -} from "recharts"; -import { workspaceService } from "../services/workspaceService"; -import { projectService } from "../services/projectService"; -import { API_BASE } from "../api/client"; -import { useDocumentTitle } from "../hooks/useDocumentTitle"; -import type { WorkspaceApiResponse, ProjectApiResponse } from "../api/types"; +} from 'recharts'; +import { workspaceService } from '../services/workspaceService'; +import { projectService } from '../services/projectService'; +import { API_BASE } from '../api/client'; +import { useDocumentTitle } from '../hooks/useDocumentTitle'; +import type { WorkspaceApiResponse, ProjectApiResponse } from '../api/types'; interface AnalyticsResponse { by_state: Record; @@ -41,20 +41,6 @@ async function downloadCsv(url: string, fallbackFilename: string) { setTimeout(() => URL.revokeObjectURL(blobUrl), 0); } -const IconSearch = () => ( - - - - -); const IconBriefcase = () => ( (null); const [exportError, setExportError] = useState(null); - useDocumentTitle("Analytics"); + useDocumentTitle('Analytics'); useEffect(() => { if (!workspaceSlug) { @@ -139,44 +125,45 @@ export function AnalyticsWorkItemsPage() { let cancelled = false; setLoading(true); workspaceService - .getBySlug(workspaceSlug) - .then((w) => { - if (cancelled) return; - setWorkspace(w); + .getBySlug(workspaceSlug) + .then((w) => { + if (cancelled) return; + setWorkspace(w); - projectService.list(workspaceSlug) - .then((projs) => { - if (!cancelled && projs) setProjects(projs); - }) - .catch((err) => console.error("Erreur projets:", err)); + projectService + .list(workspaceSlug) + .then((projs) => { + if (!cancelled && projs) setProjects(projs); + }) + .catch((err) => console.error('Erreur projets:', err)); - // Add the trailing slash back to the URL - fetch(`${API_BASE}/api/workspaces/${workspaceSlug}/analytics/`, { credentials: 'include' }) - .then((res) => { - if (!res.ok) { - throw new Error(`Code erreur serveur Go : ${res.status}`); - } - return res.json(); - }) - .then((analyticsData) => { - if (!cancelled && analyticsData) setAnalytics(analyticsData); - }) - .catch((err) => { - console.error("Erreur API Analytics Go:", err); - }); - }) - .catch((err) => { - console.error("Erreur Workspace:", err); - if (!cancelled) setWorkspace(null); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); + // Add the trailing slash back to the URL + fetch(`${API_BASE}/api/workspaces/${workspaceSlug}/analytics/`, { credentials: 'include' }) + .then((res) => { + if (!res.ok) { + throw new Error(`Code erreur serveur Go : ${res.status}`); + } + return res.json(); + }) + .then((analyticsData) => { + if (!cancelled && analyticsData) setAnalytics(analyticsData); + }) + .catch((err) => { + console.error('Erreur API Analytics Go:', err); + }); + }) + .catch((err) => { + console.error('Erreur Workspace:', err); + if (!cancelled) setWorkspace(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); - return () => { - cancelled = true; - }; -}, [workspaceSlug]); + return () => { + cancelled = true; + }; + }, [workspaceSlug]); const exportWorkspaceCsv = async () => { if (!workspaceSlug || exportingWorkspace) return; @@ -187,7 +174,7 @@ export function AnalyticsWorkItemsPage() { .toISOString() .slice(0, 10)}.csv`; await downloadCsv(`${API_BASE}/api/workspaces/${workspaceSlug}/analytics/export/`, fallback); - } catch (err) { + } catch { setExportError(t('analytics.exportFailed', 'Export failed. Please try again.')); } finally { setExportingWorkspace(false); @@ -205,9 +192,9 @@ export function AnalyticsWorkItemsPage() { .slice(0, 10)}.csv`; await downloadCsv( `${API_BASE}/api/workspaces/${workspaceSlug}/projects/${projectId}/analytics/export`, - fallback + fallback, ); - } catch (err) { + } catch { setExportError(t('analytics.exportFailed', 'Export failed. Please try again.')); } finally { setExportingProjectId(null); @@ -232,28 +219,33 @@ export function AnalyticsWorkItemsPage() { const baseUrl = `/${workspace.slug}/analytics`; - const backlogCount = analytics?.by_state["Backlog"] ?? 0; - const startedCount = analytics?.by_state["In Progress"] ?? 0; - const unstartedCount = analytics?.by_state["Todo"] ?? 0; - const completedCount = analytics?.by_state["Done"] ?? 0; + const backlogCount = analytics?.by_state['Backlog'] ?? 0; + const startedCount = analytics?.by_state['In Progress'] ?? 0; + const unstartedCount = analytics?.by_state['Todo'] ?? 0; + const completedCount = analytics?.by_state['Done'] ?? 0; const totalIssues = backlogCount + startedCount + unstartedCount + completedCount; const priorityRows = Object.entries(analytics?.by_priority ?? {}).map(([priority, count]) => ({ - priority: priority ? priority.charAt(0).toUpperCase() + priority.slice(1) : "None", - count, -})); - - const createdResolvedData: any[] = []; + priority: priority ? priority.charAt(0).toUpperCase() + priority.slice(1) : 'None', + count, + })); + const createdResolvedData: Record[] = []; return (
{/* Tabs */}
- - Overview + + {t('common.overview', 'Overview')} - - Work items + + {t('common.workItems', 'Work items')}
@@ -264,7 +256,7 @@ export function AnalyticsWorkItemsPage() { {/* KPIs */}
-

Total Work items

+

{t('analytics.totalWorkItems', 'Total Work items')}

{totalIssues}

@@ -312,29 +304,29 @@ export function AnalyticsWorkItemsPage() { /> @@ -412,29 +404,29 @@ export function AnalyticsWorkItemsPage() { /> @@ -452,7 +444,9 @@ export function AnalyticsWorkItemsPage() {
-

{priorityRows.length} Priorities

+

+ {t('analytics.prioritiesCount', '{{count}} Priorities', { count: priorityRows.length })} +

@@ -491,18 +485,20 @@ export function AnalyticsWorkItemsPage() {
{/* Projects table */} -
+
-

{projects.length} Projects

+

+ {t('analytics.projectsCount', '{{count}} Projects', { count: projects.length })} +

ProjectActionsProjectActions
+
{project.name}
- {/* Le bouton export possède désormais le project.id valide grâce à la boucle map */} +
- - + + @@ -510,7 +506,9 @@ export function AnalyticsWorkItemsPage() { @@ -521,9 +519,9 @@ export function AnalyticsWorkItemsPage() { onClick={() => exportProjectCsv(project.id)} className="flex items-center gap-1 rounded border border-(--border-subtle) bg-(--bg-layer-2) px-2 py-1 text-xs text-(--txt-secondary) hover:bg-(--bg-layer-2-hover) disabled:opacity-60" > - - {exportingProjectId === project.id - ? t('analytics.exporting', 'Exporting…') + + {exportingProjectId === project.id + ? t('analytics.exporting', 'Exporting…') : 'Export Project CSV'} @@ -535,4 +533,4 @@ export function AnalyticsWorkItemsPage() { ); -} \ No newline at end of file +} diff --git a/apps/web/src/pages/IssueListPage.tsx b/apps/web/src/pages/IssueListPage.tsx index 6289625..d060f7c 100644 --- a/apps/web/src/pages/IssueListPage.tsx +++ b/apps/web/src/pages/IssueListPage.tsx @@ -182,21 +182,25 @@ export function IssueListPage() { let cancelled = false; setLoading(true); - const safeFetch = (promise: Promise, fallback: any): Promise => { - return Promise.resolve(promise) - .then((val) => val ?? fallback) - .catch((err) => { - console.warn("Secondary request ignored (Prevents crashes)", err.message || err); - return fallback; - }); - }; + const safeFetch = (promise: Promise, fallback: T): Promise => { + return Promise.resolve(promise) + .then((val) => val ?? fallback) + .catch((err: unknown) => { + const errorMessage = err instanceof Error ? err.message : String(err); + console.warn('Secondary request ignored (Prevents crashes)', errorMessage); + return fallback; + }); +}; Promise.all([ workspaceService.getBySlug(workspaceSlug), projectService.get(workspaceSlug, projectId), safeFetch(projectService.list(workspaceSlug), [] as ProjectApiResponse[]), - safeFetch(issueService.list(workspaceSlug, projectId, { limit: 100 }), [] as IssueApiResponse[]), + safeFetch( + issueService.list(workspaceSlug, projectId, { limit: 100 }), + [] as IssueApiResponse[], + ), safeFetch(stateService.list(workspaceSlug, projectId), [] as StateApiResponse[]), safeFetch(labelService.list(workspaceSlug, projectId), [] as LabelApiResponse[]), safeFetch(cycleService.list(workspaceSlug, projectId), [] as CycleApiResponse[]), @@ -216,7 +220,7 @@ export function IssueListPage() { setMembers(mem); }) .catch((err) => { - console.error("Critical error when loading Project or Workspace : ", err); + console.error('Critical error when loading Project or Workspace : ', err); if (!cancelled) { setWorkspace(null); setProject(null); @@ -586,8 +590,6 @@ export function IssueListPage() { ); } if (!workspace || !project) { - console.log(project) - console.log(workspace) return (
{t('common.projectNotFound', 'Project not found.')} From 1a96f88756f70cadab647f9d2af94ea1afceeabe Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Jul 2026 22:46:25 -0400 Subject: [PATCH 12/28] added a service for tonight but might have to recheck it with what i just figured out --- apps/api/internal/service/analytics.go | 151 +++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 apps/api/internal/service/analytics.go diff --git a/apps/api/internal/service/analytics.go b/apps/api/internal/service/analytics.go new file mode 100644 index 0000000..645712c --- /dev/null +++ b/apps/api/internal/service/analytics.go @@ -0,0 +1,151 @@ +type analyticsService struct { + store AnalyticsStore + log *slog.Logger + // DB handles authorization / existence checks inside the service + db *gorm.DB +} + +func NewAnalyticsService(store AnalyticsStore, log *slog.Logger, db *gorm.DB) AnalyticsService { + return &analyticsService{ + store: store, + log: log, + db: db, + } +} + +// checkWorkspaceAccess handles validation of workspace membership & workspace existence +func (s *analyticsService) checkWorkspaceAccess(userID string, slug string) error { + var count int64 + // Simple validation to verify the workspace exists and the user is an active member + err := s.db.Table("workspaces"). + Joins("INNER JOIN workspace_members ON workspaces.id = workspace_members.workspace_id"). + Where("workspaces.slug = ? AND workspace_members.user_id = ? AND workspaces.deleted_at IS NULL", slug, userID). + Count(&count).Error + + if err != nil || count == 0 { + return fmt.Errorf("unauthorized workspace access or workspace does not exist") + } + return nil +} + +// checkProjectAccess validates project presence, project slugs/IDs, and membership +func (s *analyticsService) checkProjectAccess(userID string, projectID string) error { + var count int64 + // Validates whether the project exists, and whether the user is authorized to access it + err := s.db.Table("projects"). + Joins("INNER JOIN workspaces ON projects.workspace_id = workspaces.id"). + Joins("INNER JOIN workspace_members ON workspaces.id = workspace_members.workspace_id"). + Where("projects.id = ? AND workspace_members.user_id = ? AND projects.deleted_at IS NULL", projectID, userID). + Count(&count).Error + + if err != nil || count == 0 { + return fmt.Errorf("unauthorized project access or project does not exist") + } + return nil +} + +func (s *analyticsService) GetWorkspaceAnalytics(userID string, slug string) (*AnalyticsResponse, error) { + if err := s.checkWorkspaceAccess(userID, slug); err != nil { + return nil, err + } + + byState := make(map[string]int64) + if stateResults, err := s.store.GetWorkspaceStateAnalytics(slug); err == nil { + for _, r := range stateResults { + byState[r.State] = r.Count + } + } else { + return nil, err + } + + byPriority := make(map[string]int64) + if priorityResults, err := s.store.GetWorkspacePriorityAnalytics(slug); err == nil { + for _, r := range priorityResults { + byPriority[r.Priority] = r.Count + } + } else { + return nil, err + } + + byAssignee := make(map[string]int64) + if assigneeResults, err := s.store.GetWorkspaceAssigneeAnalytics(slug); err == nil { + for _, r := range assigneeResults { + byAssignee[r.Email] = r.Count + } + } else { + s.log.Warn("failed to fetch workspace assignee analytics", "error", err) + } + + byLabel := make(map[string]int64) + if labelResults, err := s.store.GetWorkspaceLabelAnalytics(slug); err == nil { + for _, r := range labelResults { + byLabel[r.Label] = r.Count + } + } else { + s.log.Warn("failed to fetch workspace label analytics", "error", err) + } + + return &AnalyticsResponse{ + ByState: byState, + ByPriority: byPriority, + ByAssignee: byAssignee, + ByLabel: byLabel, + }, nil +} + +func (s *analyticsService) GetProjectAnalytics(userID string, projectID string) (*AnalyticsResponse, error) { + if err := s.checkProjectAccess(userID, projectID); err != nil { + return nil, err + } + + byState := make(map[string]int64) + if stateResults, err := s.store.GetProjectStateAnalytics(projectID); err == nil { + for _, r := range stateResults { + byState[r.State] = r.Count + } + } else { + return nil, err + } + + byPriority := make(map[string]int64) + if priorityResults, err := s.store.GetProjectPriorityAnalytics(projectID); err == nil { + for _, r := range priorityResults { + byPriority[r.Priority] = r.Count + } + } + + byAssignee := make(map[string]int64) + if assigneeResults, err := s.store.GetProjectAssigneeAnalytics(projectID); err == nil { + for _, r := range assigneeResults { + byAssignee[r.Email] = r.Count + } + } + + byLabel := make(map[string]int64) + if labelResults, err := s.store.GetProjectLabelAnalytics(projectID); err == nil { + for _, r := range labelResults { + byLabel[r.Label] = r.Count + } + } + + return &AnalyticsResponse{ + ByState: byState, + ByPriority: byPriority, + ByAssignee: byAssignee, + ByLabel: byLabel, + }, nil +} + +func (s *analyticsService) ExportWorkspaceCSV(userID string, slug string) ([]WorkspaceIssueExport, error) { + if err := s.checkWorkspaceAccess(userID, slug); err != nil { + return nil, err + } + return s.store.GetWorkspaceIssuesForExport(slug) +} + +func (s *analyticsService) ExportProjectCSV(userID string, projectID string) ([]ProjectIssueExport, error) { + if err := s.checkProjectAccess(userID, projectID); err != nil { + return nil, err + } + return s.store.GetProjectIssuesForExport(projectID) +} \ No newline at end of file From 15c38da340b1e6e81a0b9af49130561cd95b229b Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 22 Jul 2026 06:46:30 -0400 Subject: [PATCH 13/28] pull request corrections --- apps/api/internal/handler/analytics.go | 189 ++++++++++++++++-- apps/api/internal/router/router.go | 6 +- apps/web/src/pages/AnalyticsWorkItemsPage.tsx | 133 +++++++----- 3 files changed, 257 insertions(+), 71 deletions(-) diff --git a/apps/api/internal/handler/analytics.go b/apps/api/internal/handler/analytics.go index a1c64cc..5df4e3d 100644 --- a/apps/api/internal/handler/analytics.go +++ b/apps/api/internal/handler/analytics.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "sort" "time" "github.com/gin-gonic/gin" @@ -16,11 +17,41 @@ type AnalyticsHandler struct { Log *slog.Logger } +// TrendPoint is one day's worth of created-vs-resolved counts for the +// created/resolved trend chart. +type TrendPoint struct { + Date string `json:"date"` + Created int64 `json:"created"` + Resolved int64 `json:"resolved"` +} + 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"` + Trend []TrendPoint `json:"trend"` + // PartialError is true when one or more secondary aggregates (assignee, + // label, trend) failed to load. ByState/ByPriority failures still return + // a 500 below since those are required for the page to render at all. + PartialError bool `json:"partial_error,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// sanitizeCSVField neutralizes spreadsheet-formula injection: if a +// user-controlled cell starts with a character that Excel/Sheets/LibreOffice +// interpret as a formula trigger, prefix it with a single quote so it's +// imported as plain text instead of being evaluated. +func sanitizeCSVField(v string) string { + if v == "" { + return v + } + switch v[0] { + case '=', '+', '-', '@', '\t', '\r': + return "'" + v + default: + return v + } } func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) { @@ -73,6 +104,9 @@ func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) { byPriority[r.Priority] = r.Count } + var warnings []string + partialError := false + // 3. Counts by Assignee (Many-to-Many via issue_assignees) var assigneeResults []struct { Email string @@ -94,6 +128,8 @@ func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) { } } else { h.Log.Warn("failed to fetch workspace assignee analytics", "error", err) + partialError = true + warnings = append(warnings, "assignee breakdown unavailable") } // 4. Counts by Label (Many-to-Many via issue_labels) @@ -117,16 +153,88 @@ func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) { } } else { h.Log.Warn("failed to fetch workspace label analytics", "error", err) + partialError = true + warnings = append(warnings, "label breakdown unavailable") + } + + // 5. Created vs Resolved trend, last 30 days. + trend, err := h.workspaceTrend(slug) + if err != nil { + h.Log.Warn("failed to fetch workspace created/resolved trend", "error", err) + partialError = true + warnings = append(warnings, "created/resolved trend unavailable") } c.JSON(http.StatusOK, AnalyticsResponse{ - ByState: byState, - ByPriority: byPriority, - ByAssignee: byAssignee, - ByLabel: byLabel, + ByState: byState, + ByPriority: byPriority, + ByAssignee: byAssignee, + ByLabel: byLabel, + Trend: trend, + PartialError: partialError, + Warnings: warnings, }) } +// workspaceTrend returns per-day created and resolved issue counts for the +// last 30 days for the given workspace. "Resolved" is approximated as the +// last-updated date of issues currently sitting in a "completed" state +// group, since the schema doesn't carry a dedicated completed-at timestamp. +func (h *AnalyticsHandler) workspaceTrend(slug string) ([]TrendPoint, error) { + var createdRows []struct { + Day time.Time + Count int64 + } + if err := h.DB.Table("issues"). + Select("date_trunc('day', issues.created_at) as day, 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.created_at >= ?", slug, time.Now().AddDate(0, 0, -30)). + Group("day"). + Scan(&createdRows).Error; err != nil { + return nil, err + } + + var resolvedRows []struct { + Day time.Time + Count int64 + } + if err := h.DB.Table("issues"). + Select("date_trunc('day', issues.updated_at) as day, 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 AND states."group" = ? AND issues.updated_at >= ?`, slug, "completed", time.Now().AddDate(0, 0, -30)). + Group("day"). + Scan(&resolvedRows).Error; err != nil { + return nil, err + } + + byDay := make(map[string]*TrendPoint) + order := make([]string, 0, 30) + get := func(day time.Time) *TrendPoint { + key := day.Format("2006-01-02") + if tp, ok := byDay[key]; ok { + return tp + } + tp := &TrendPoint{Date: key} + byDay[key] = tp + order = append(order, key) + return tp + } + for _, r := range createdRows { + get(r.Day).Created = r.Count + } + for _, r := range resolvedRows { + get(r.Day).Resolved = r.Count + } + + sort.Strings(order) + trend := make([]TrendPoint, 0, len(order)) + for _, key := range order { + trend = append(trend, *byDay[key]) + } + return trend, nil +} + func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { projectID := c.Param("projectId") if projectID == "" { @@ -167,11 +275,18 @@ func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { Group("issues.priority"). Scan(&priorityResults).Error + var warnings []string + partialError := false + byPriority := make(map[string]int64) if err == nil { for _, r := range priorityResults { byPriority[r.Priority] = r.Count } + } else { + h.Log.Warn("failed to fetch project priority analytics", "error", err, "project_id", projectID) + partialError = true + warnings = append(warnings, "priority breakdown unavailable") } // 3. Project Counts by Assignee @@ -192,6 +307,10 @@ func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { for _, r := range assigneeResults { byAssignee[r.Email] = r.Count } + } else { + h.Log.Warn("failed to fetch project assignee analytics", "error", err, "project_id", projectID) + partialError = true + warnings = append(warnings, "assignee breakdown unavailable") } // 4. Project Counts by Label @@ -212,13 +331,19 @@ func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { for _, r := range labelResults { byLabel[r.Label] = r.Count } + } else { + h.Log.Warn("failed to fetch project label analytics", "error", err, "project_id", projectID) + partialError = true + warnings = append(warnings, "label breakdown unavailable") } c.JSON(http.StatusOK, AnalyticsResponse{ - ByState: byState, - ByPriority: byPriority, - ByAssignee: byAssignee, - ByLabel: byLabel, + ByState: byState, + ByPriority: byPriority, + ByAssignee: byAssignee, + ByLabel: byLabel, + PartialError: partialError, + Warnings: warnings, }) } @@ -252,17 +377,28 @@ func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { c.Header("Content-Transfer-Encoding", "binary") writer := csv.NewWriter(c.Writer) - defer writer.Flush() - _ = writer.Write([]string{"Issue ID", "Title", "State", "Priority"}) + if err := writer.Write([]string{"Issue ID", "Title", "State", "Priority"}); err != nil { + h.Log.Error("failed to write CSV header for workspace export", "error", err, "slug", slug) + return + } for _, issue := range issues { - _ = writer.Write([]string{ + row := []string{ issue.ID, - issue.Name, - issue.State, - issue.Priority, - }) + sanitizeCSVField(issue.Name), + sanitizeCSVField(issue.State), + sanitizeCSVField(issue.Priority), + } + if err := writer.Write(row); err != nil { + h.Log.Error("failed to write CSV row for workspace export", "error", err, "slug", slug) + return + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + h.Log.Error("failed to flush CSV for workspace export", "error", err, "slug", slug) } } @@ -295,15 +431,26 @@ func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { c.Header("Content-Type", "text/csv") writer := csv.NewWriter(c.Writer) - defer writer.Flush() - _ = writer.Write([]string{"Project Issue ID", "Title", "State"}) + if err := writer.Write([]string{"Project Issue ID", "Title", "State"}); err != nil { + h.Log.Error("failed to write CSV header for project export", "error", err, "project_id", projectID) + return + } for _, issue := range issues { - _ = writer.Write([]string{ + row := []string{ issue.ID, - issue.Name, - issue.State, - }) + sanitizeCSVField(issue.Name), + sanitizeCSVField(issue.State), + } + if err := writer.Write(row); err != nil { + h.Log.Error("failed to write CSV row for project export", "error", err, "project_id", projectID) + return + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + h.Log.Error("failed to flush CSV for project export", "error", err, "project_id", projectID) } } \ No newline at end of file diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index fb7910a..2ca5933 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -433,8 +433,8 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { 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/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) @@ -628,4 +628,4 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { } return r, importerSvc -} +} \ No newline at end of file diff --git a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx index 0cad422..68bf69a 100644 --- a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx +++ b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx @@ -18,9 +18,33 @@ import { API_BASE } from '../api/client'; import { useDocumentTitle } from '../hooks/useDocumentTitle'; import type { WorkspaceApiResponse, ProjectApiResponse } from '../api/types'; +interface TrendPoint { + date: string; + created: number; + resolved: number; +} + interface AnalyticsResponse { by_state: Record; by_priority: Record; + trend?: TrendPoint[]; + partial_error?: boolean; + warnings?: string[]; +} + +const STATE_GROUP_SYNONYMS: Record<'backlog' | 'unstarted' | 'started' | 'completed', string[]> = { + backlog: ['backlog'], + unstarted: ['todo', 'to do', 'unstarted'], + started: ['in progress', 'started'], + completed: ['done', 'completed'], +}; + +function sumStateGroup(byState: Record, synonyms: string[]): number { + const wanted = new Set(synonyms.map((s) => s.toLowerCase())); + return Object.entries(byState).reduce( + (sum, [name, count]) => (wanted.has(name.toLowerCase()) ? sum + count : sum), + 0, + ); } // Safely downloads the CSV via fetch using credentials (cookies) and correct absolute API URL @@ -108,6 +132,7 @@ export function AnalyticsWorkItemsPage() { const [projects, setProjects] = useState([]); const [analytics, setAnalytics] = useState(null); + const [analyticsError, setAnalyticsError] = useState(null); const [loading, setLoading] = useState(true); // States to track downloads @@ -124,46 +149,55 @@ export function AnalyticsWorkItemsPage() { } let cancelled = false; setLoading(true); - workspaceService - .getBySlug(workspaceSlug) - .then((w) => { - if (cancelled) return; - setWorkspace(w); - - projectService - .list(workspaceSlug) - .then((projs) => { - if (!cancelled && projs) setProjects(projs); - }) - .catch((err) => console.error('Erreur projets:', err)); - - // Add the trailing slash back to the URL - fetch(`${API_BASE}/api/workspaces/${workspaceSlug}/analytics/`, { credentials: 'include' }) - .then((res) => { - if (!res.ok) { - throw new Error(`Code erreur serveur Go : ${res.status}`); - } - return res.json(); - }) - .then((analyticsData) => { - if (!cancelled && analyticsData) setAnalytics(analyticsData); - }) - .catch((err) => { - console.error('Erreur API Analytics Go:', err); - }); - }) - .catch((err) => { + setAnalyticsError(null); + + (async () => { + let w: WorkspaceApiResponse | null = null; + try { + w = await workspaceService.getBySlug(workspaceSlug); + } catch (err) { console.error('Erreur Workspace:', err); if (!cancelled) setWorkspace(null); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); + return; + } + if (cancelled) return; + setWorkspace(w); + + const [projectsResult, analyticsResult] = await Promise.allSettled([ + projectService.list(workspaceSlug), + fetch(`${API_BASE}/api/workspaces/${workspaceSlug}/analytics/`, { + credentials: 'include', + }).then((res) => { + if (!res.ok) { + throw new Error(`Code erreur serveur Go : ${res.status}`); + } + return res.json() as Promise; + }), + ]); + if (cancelled) return; + + if (projectsResult.status === 'fulfilled') { + if (projectsResult.value) setProjects(projectsResult.value); + } else { + console.error('Erreur projets:', projectsResult.reason); + } + + if (analyticsResult.status === 'fulfilled') { + setAnalytics(analyticsResult.value); + } else { + console.error('Erreur API Analytics Go:', analyticsResult.reason); + setAnalyticsError( + t('analytics.loadFailed', 'Could not load analytics. Please try again.'), + ); + } + })().finally(() => { + if (!cancelled) setLoading(false); + }); return () => { cancelled = true; }; - }, [workspaceSlug]); + }, [workspaceSlug, t]); const exportWorkspaceCsv = async () => { if (!workspaceSlug || exportingWorkspace) return; @@ -191,7 +225,7 @@ export function AnalyticsWorkItemsPage() { .toISOString() .slice(0, 10)}.csv`; await downloadCsv( - `${API_BASE}/api/workspaces/${workspaceSlug}/projects/${projectId}/analytics/export`, + `${API_BASE}/api/workspaces/${workspaceSlug}/projects/${projectId}/analytics/export/`, fallback, ); } catch { @@ -219,18 +253,19 @@ export function AnalyticsWorkItemsPage() { const baseUrl = `/${workspace.slug}/analytics`; - const backlogCount = analytics?.by_state['Backlog'] ?? 0; - const startedCount = analytics?.by_state['In Progress'] ?? 0; - const unstartedCount = analytics?.by_state['Todo'] ?? 0; - const completedCount = analytics?.by_state['Done'] ?? 0; - const totalIssues = backlogCount + startedCount + unstartedCount + completedCount; + const byState = analytics?.by_state ?? {}; + const backlogCount = sumStateGroup(byState, STATE_GROUP_SYNONYMS.backlog); + const startedCount = sumStateGroup(byState, STATE_GROUP_SYNONYMS.started); + const unstartedCount = sumStateGroup(byState, STATE_GROUP_SYNONYMS.unstarted); + const completedCount = sumStateGroup(byState, STATE_GROUP_SYNONYMS.completed); + const totalIssues = Object.values(byState).reduce((sum, count) => sum + count, 0); const priorityRows = Object.entries(analytics?.by_priority ?? {}).map(([priority, count]) => ({ priority: priority ? priority.charAt(0).toUpperCase() + priority.slice(1) : 'None', count, })); - const createdResolvedData: Record[] = []; + const createdResolvedData: TrendPoint[] = analytics?.trend ?? []; return (
{/* Tabs */} @@ -253,6 +288,10 @@ export function AnalyticsWorkItemsPage() { {t('analytics.workItems', 'Work items')} + {analyticsError && ( +

{analyticsError}

+ )} + {/* KPIs */}
@@ -308,7 +347,7 @@ export function AnalyticsWorkItemsPage() { tickLine={{ stroke: 'var(--border-subtle)' }} axisLine={{ stroke: 'var(--border-subtle)' }} label={{ - value: 'DATE', + value: t('analytics.axisDate', 'DATE'), position: 'insideBottom', offset: -4, fill: 'var(--txt-tertiary)', @@ -320,7 +359,7 @@ export function AnalyticsWorkItemsPage() { tickLine={{ stroke: 'var(--border-subtle)' }} axisLine={{ stroke: 'var(--border-subtle)' }} label={{ - value: 'NO. OF WORK ITEMS', + value: t('analytics.axisWorkItemsCount', 'NO. OF WORK ITEMS'), angle: -90, position: 'insideLeft', fill: 'var(--txt-tertiary)', @@ -408,7 +447,7 @@ export function AnalyticsWorkItemsPage() { tickLine={{ stroke: 'var(--border-subtle)' }} axisLine={{ stroke: 'var(--border-subtle)' }} label={{ - value: 'PRIORITY', + value: t('analytics.axisPriority', 'PRIORITY'), position: 'insideBottom', offset: -4, fill: 'var(--txt-tertiary)', @@ -420,7 +459,7 @@ export function AnalyticsWorkItemsPage() { tickLine={{ stroke: 'var(--border-subtle)' }} axisLine={{ stroke: 'var(--border-subtle)' }} label={{ - value: 'NO. OF WORK ITEM', + value: t('analytics.axisWorkItemCount', 'NO. OF WORK ITEM'), angle: -90, position: 'insideLeft', fill: 'var(--txt-tertiary)', @@ -522,7 +561,7 @@ export function AnalyticsWorkItemsPage() { {exportingProjectId === project.id ? t('analytics.exporting', 'Exporting…') - : 'Export Project CSV'} + : t('analytics.exportProjectCsv', 'Export Project CSV')}
@@ -533,4 +572,4 @@ export function AnalyticsWorkItemsPage() { ); -} +} \ No newline at end of file From 438b4d4af1d5c63e9d36d9e0fce9ee13fcdfc0da Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 11:11:19 -0400 Subject: [PATCH 14/28] added analytics as a store --- apps/api/internal/router/router.go | 8 +++++--- apps/api/internal/service/analytics.go | 14 +++++++++++--- apps/api/internal/store/analytics.go | 0 3 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 apps/api/internal/store/analytics.go diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 2ca5933..b2a514f 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -245,9 +245,11 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { AppBaseURL: appBaseURL, } + analyticsSvc := service.NewAnalyticsService(cfg.DB, cfg.Log) + analyticsHandler := &handler.AnalyticsHandler{ - DB: cfg.DB, - Log: cfg.Log, + AnalyticsService: analyticsSvc, + Log: cfg.Log, } projectHandler := &handler.ProjectHandler{Project: projectSvc, State: stateSvc} @@ -628,4 +630,4 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { } return r, importerSvc -} \ No newline at end of file +} diff --git a/apps/api/internal/service/analytics.go b/apps/api/internal/service/analytics.go index 645712c..0faf892 100644 --- a/apps/api/internal/service/analytics.go +++ b/apps/api/internal/service/analytics.go @@ -1,8 +1,16 @@ +package service + +import ( + "fmt" + "log/slog" + + "gorm.io/gorm" +) + type analyticsService struct { store AnalyticsStore log *slog.Logger // DB handles authorization / existence checks inside the service - db *gorm.DB } func NewAnalyticsService(store AnalyticsStore, log *slog.Logger, db *gorm.DB) AnalyticsService { @@ -28,7 +36,7 @@ func (s *analyticsService) checkWorkspaceAccess(userID string, slug string) erro return nil } -// checkProjectAccess validates project presence, project slugs/IDs, and membership +// checkProjectAccess validates project presence, project slugs/IDs, and membership func (s *analyticsService) checkProjectAccess(userID string, projectID string) error { var count int64 // Validates whether the project exists, and whether the user is authorized to access it @@ -148,4 +156,4 @@ func (s *analyticsService) ExportProjectCSV(userID string, projectID string) ([] return nil, err } return s.store.GetProjectIssuesForExport(projectID) -} \ No newline at end of file +} diff --git a/apps/api/internal/store/analytics.go b/apps/api/internal/store/analytics.go new file mode 100644 index 0000000..e69de29 From 97edaf9615635c7ac705485dd4dff258c04aa940 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 11:26:19 -0400 Subject: [PATCH 15/28] the store is missing, --- apps/api/internal/handler/analytics.go | 453 ++++++------------------- apps/api/internal/router/router.go | 7 +- apps/api/internal/service/analytics.go | 116 ++++--- apps/api/internal/store/analytics.go | 5 + 4 files changed, 171 insertions(+), 410 deletions(-) diff --git a/apps/api/internal/handler/analytics.go b/apps/api/internal/handler/analytics.go index 5df4e3d..aebf6ec 100644 --- a/apps/api/internal/handler/analytics.go +++ b/apps/api/internal/handler/analytics.go @@ -2,46 +2,23 @@ package handler import ( "encoding/csv" + "errors" "fmt" "log/slog" "net/http" - "sort" "time" + "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/service" "github.com/gin-gonic/gin" - "gorm.io/gorm" + "github.com/google/uuid" ) type AnalyticsHandler struct { - DB *gorm.DB - Log *slog.Logger + AnalyticsService service.AnalyticsService + Log *slog.Logger } -// TrendPoint is one day's worth of created-vs-resolved counts for the -// created/resolved trend chart. -type TrendPoint struct { - Date string `json:"date"` - Created int64 `json:"created"` - Resolved int64 `json:"resolved"` -} - -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"` - Trend []TrendPoint `json:"trend"` - // PartialError is true when one or more secondary aggregates (assignee, - // label, trend) failed to load. ByState/ByPriority failures still return - // a 500 below since those are required for the page to render at all. - PartialError bool `json:"partial_error,omitempty"` - Warnings []string `json:"warnings,omitempty"` -} - -// sanitizeCSVField neutralizes spreadsheet-formula injection: if a -// user-controlled cell starts with a character that Excel/Sheets/LibreOffice -// interpret as a formula trigger, prefix it with a single quote so it's -// imported as plain text instead of being evaluated. func sanitizeCSVField(v string) string { if v == "" { return v @@ -54,374 +31,150 @@ func sanitizeCSVField(v string) string { } } -func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) { - slug := c.Param("slug") - - // 1. Counts by State - var stateResults []struct { - State string - Count int64 +func parseParamUUID(c *gin.Context, key string) (uuid.UUID, bool) { + val := c.Param(key) + if val == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing ID parameter"}) + return uuid.Nil, false } - 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 - + parsed, err := uuid.Parse(val) 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 + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid UUID parameter"}) + return uuid.Nil, false } - 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 + return parsed, true +} - if err != nil { - h.Log.Error("failed to fetch workspace priority analytics", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) +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 } - byPriority := make(map[string]int64) - for _, r := range priorityResults { - byPriority[r.Priority] = r.Count - } - - var warnings []string - partialError := false - - // 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) - partialError = true - warnings = append(warnings, "assignee breakdown unavailable") - } - - // 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) - partialError = true - warnings = append(warnings, "label breakdown unavailable") - } - - // 5. Created vs Resolved trend, last 30 days. - trend, err := h.workspaceTrend(slug) + res, err := h.AnalyticsService.GetWorkspaceAnalytics(c.Request.Context(), user.ID, slug) if err != nil { - h.Log.Warn("failed to fetch workspace created/resolved trend", "error", err) - partialError = true - warnings = append(warnings, "created/resolved trend unavailable") - } - - c.JSON(http.StatusOK, AnalyticsResponse{ - ByState: byState, - ByPriority: byPriority, - ByAssignee: byAssignee, - ByLabel: byLabel, - Trend: trend, - PartialError: partialError, - Warnings: warnings, - }) -} - -// workspaceTrend returns per-day created and resolved issue counts for the -// last 30 days for the given workspace. "Resolved" is approximated as the -// last-updated date of issues currently sitting in a "completed" state -// group, since the schema doesn't carry a dedicated completed-at timestamp. -func (h *AnalyticsHandler) workspaceTrend(slug string) ([]TrendPoint, error) { - var createdRows []struct { - Day time.Time - Count int64 - } - if err := h.DB.Table("issues"). - Select("date_trunc('day', issues.created_at) as day, 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.created_at >= ?", slug, time.Now().AddDate(0, 0, -30)). - Group("day"). - Scan(&createdRows).Error; err != nil { - return nil, err - } - - var resolvedRows []struct { - Day time.Time - Count int64 - } - if err := h.DB.Table("issues"). - Select("date_trunc('day', issues.updated_at) as day, 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 AND states."group" = ? AND issues.updated_at >= ?`, slug, "completed", time.Now().AddDate(0, 0, -30)). - Group("day"). - Scan(&resolvedRows).Error; err != nil { - return nil, err - } - - byDay := make(map[string]*TrendPoint) - order := make([]string, 0, 30) - get := func(day time.Time) *TrendPoint { - key := day.Format("2006-01-02") - if tp, ok := byDay[key]; ok { - return tp + if errors.Is(err, service.ErrAnalyticsForbidden) { + c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) + return } - tp := &TrendPoint{Date: key} - byDay[key] = tp - order = append(order, key) - return tp - } - for _, r := range createdRows { - get(r.Day).Created = r.Count - } - for _, r := range resolvedRows { - get(r.Day).Resolved = r.Count + if errors.Is(err, service.ErrAnalyticsNotFound) { + 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 } - sort.Strings(order) - trend := make([]TrendPoint, 0, len(order)) - for _, key := range order { - trend = append(trend, *byDay[key]) - } - return trend, nil + c.JSON(http.StatusOK, res) } func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { - projectID := c.Param("projectId") - if projectID == "" { - projectID = c.Param("projectID") + projectParam := c.Param("projectId") + if projectParam == "" { + projectParam = 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 - + projectID, err := uuid.Parse(projectParam) if err != nil { - h.Log.Error("failed to fetch project analytics", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) 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 - - var warnings []string - partialError := false - - byPriority := make(map[string]int64) - if err == nil { - for _, r := range priorityResults { - byPriority[r.Priority] = r.Count - } - } else { - h.Log.Warn("failed to fetch project priority analytics", "error", err, "project_id", projectID) - partialError = true - warnings = append(warnings, "priority breakdown unavailable") - } - - // 3. Project Counts by Assignee - var assigneeResults []struct { - Email string - Count int64 + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return } - 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 + res, err := h.AnalyticsService.GetProjectAnalytics(c.Request.Context(), user.ID, projectID) + if err != nil { + if errors.Is(err, service.ErrAnalyticsForbidden) { + c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) + return } - } else { - h.Log.Warn("failed to fetch project assignee analytics", "error", err, "project_id", projectID) - partialError = true - warnings = append(warnings, "assignee breakdown unavailable") - } - - // 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 + if errors.Is(err, service.ErrAnalyticsNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return } - } else { - h.Log.Warn("failed to fetch project label analytics", "error", err, "project_id", projectID) - partialError = true - warnings = append(warnings, "label breakdown unavailable") + 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, AnalyticsResponse{ - ByState: byState, - ByPriority: byPriority, - ByAssignee: byAssignee, - ByLabel: byLabel, - PartialError: partialError, - Warnings: warnings, - }) + c.JSON(http.StatusOK, res) } func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { slug := c.Param("slug") - - var issues []struct { - ID string - Name string - State string - Priority string + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return } - 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 - + issues, err := h.AnalyticsService.ExportWorkspaceCSV(c.Request.Context(), user.ID, slug) if err != nil { - h.Log.Error("failed to fetch workspace issues for CSV export", "error", err) + if errors.Is(err, service.ErrAnalyticsForbidden) { + c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) + return + } + if errors.Is(err, service.ErrAnalyticsNotFound) { + 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 } 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) - - if err := writer.Write([]string{"Issue ID", "Title", "State", "Priority"}); err != nil { - h.Log.Error("failed to write CSV header for workspace export", "error", err, "slug", slug) - return - } + _ = writer.Write([]string{"Issue ID", "Title", "State", "Priority"}) for _, issue := range issues { - row := []string{ + _ = writer.Write([]string{ issue.ID, sanitizeCSVField(issue.Name), sanitizeCSVField(issue.State), sanitizeCSVField(issue.Priority), - } - if err := writer.Write(row); err != nil { - h.Log.Error("failed to write CSV row for workspace export", "error", err, "slug", slug) - return - } + }) } - writer.Flush() - if err := writer.Error(); err != nil { - h.Log.Error("failed to flush CSV for workspace export", "error", err, "slug", slug) - } } func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { - projectID := c.Param("projectId") - if projectID == "" { - projectID = c.Param("projectID") + projectParam := c.Param("projectId") + if projectParam == "" { + projectParam = c.Param("projectID") } - - var issues []struct { - ID string - Name string - State string + projectID, err := uuid.Parse(projectParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) + return } - 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 + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + issues, err := h.AnalyticsService.ExportProjectCSV(c.Request.Context(), user.ID, projectID) if err != nil { - h.Log.Error("failed to fetch project issues for CSV export", "error", err) + if errors.Is(err, service.ErrAnalyticsForbidden) { + c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) + return + } + if errors.Is(err, service.ErrAnalyticsNotFound) { + 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 } @@ -431,26 +184,14 @@ func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { c.Header("Content-Type", "text/csv") writer := csv.NewWriter(c.Writer) - - if err := writer.Write([]string{"Project Issue ID", "Title", "State"}); err != nil { - h.Log.Error("failed to write CSV header for project export", "error", err, "project_id", projectID) - return - } + _ = writer.Write([]string{"Project Issue ID", "Title", "State"}) for _, issue := range issues { - row := []string{ + _ = writer.Write([]string{ issue.ID, sanitizeCSVField(issue.Name), sanitizeCSVField(issue.State), - } - if err := writer.Write(row); err != nil { - h.Log.Error("failed to write CSV row for project export", "error", err, "project_id", projectID) - return - } + }) } - writer.Flush() - if err := writer.Error(); err != nil { - h.Log.Error("failed to flush CSV for project export", "error", err, "project_id", projectID) - } -} \ No newline at end of file +} diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index b2a514f..1063f61 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -245,8 +245,13 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { AppBaseURL: appBaseURL, } - analyticsSvc := service.NewAnalyticsService(cfg.DB, cfg.Log) + // Store initialization + analyticsStore := store.NewAnalyticsStore(cfg.DB) + // Service initialization + analyticsSvc := service.NewAnalyticsService(analyticsStore, cfg.Log, cfg.DB) + + // Handler initialization analyticsHandler := &handler.AnalyticsHandler{ AnalyticsService: analyticsSvc, Log: cfg.Log, diff --git a/apps/api/internal/service/analytics.go b/apps/api/internal/service/analytics.go index 0faf892..5c227bc 100644 --- a/apps/api/internal/service/analytics.go +++ b/apps/api/internal/service/analytics.go @@ -1,96 +1,106 @@ package service import ( + "context" + "errors" "fmt" "log/slog" - "gorm.io/gorm" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" ) +var ( + ErrAnalyticsForbidden = errors.New("unauthorized workspace/project access") + ErrAnalyticsNotFound = errors.New("workspace or project not found") +) + +type AnalyticsService interface { + GetWorkspaceAnalytics(ctx context.Context, userID uuid.UUID, slug string) (*AnalyticsResponse, error) + GetProjectAnalytics(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (*AnalyticsResponse, error) + ExportWorkspaceCSV(ctx context.Context, userID uuid.UUID, slug string) ([]WorkspaceIssueExport, error) + ExportProjectCSV(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) ([]ProjectIssueExport, error) +} + type analyticsService struct { - store AnalyticsStore + store store.AnalyticsStore + ws *store.WorkspaceStore + ps *store.ProjectStore log *slog.Logger - // DB handles authorization / existence checks inside the service } -func NewAnalyticsService(store AnalyticsStore, log *slog.Logger, db *gorm.DB) AnalyticsService { +func NewAnalyticsService(store store.AnalyticsStore, ws *store.WorkspaceStore, ps *store.ProjectStore, log *slog.Logger) AnalyticsService { return &analyticsService{ store: store, + ws: ws, + ps: ps, log: log, - db: db, } } -// checkWorkspaceAccess handles validation of workspace membership & workspace existence -func (s *analyticsService) checkWorkspaceAccess(userID string, slug string) error { - var count int64 - // Simple validation to verify the workspace exists and the user is an active member - err := s.db.Table("workspaces"). - Joins("INNER JOIN workspace_members ON workspaces.id = workspace_members.workspace_id"). - Where("workspaces.slug = ? AND workspace_members.user_id = ? AND workspaces.deleted_at IS NULL", slug, userID). - Count(&count).Error - - if err != nil || count == 0 { - return fmt.Errorf("unauthorized workspace access or workspace does not exist") +func (s *analyticsService) ensureWorkspaceAccess(ctx context.Context, userID uuid.UUID, slug string) error { + wrk, err := s.ws.GetBySlug(ctx, slug) + if err != nil || wrk == nil { + return ErrAnalyticsNotFound + } + ok, err := s.ws.IsMember(ctx, wrk.ID, userID) + if err != nil || !ok { + return ErrAnalyticsForbidden } return nil } -// checkProjectAccess validates project presence, project slugs/IDs, and membership -func (s *analyticsService) checkProjectAccess(userID string, projectID string) error { - var count int64 - // Validates whether the project exists, and whether the user is authorized to access it - err := s.db.Table("projects"). - Joins("INNER JOIN workspaces ON projects.workspace_id = workspaces.id"). - Joins("INNER JOIN workspace_members ON workspaces.id = workspace_members.workspace_id"). - Where("projects.id = ? AND workspace_members.user_id = ? AND projects.deleted_at IS NULL", projectID, userID). - Count(&count).Error - - if err != nil || count == 0 { - return fmt.Errorf("unauthorized project access or project does not exist") +func (s *analyticsService) ensureProjectAccess(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) error { + project, err := s.ps.GetByID(ctx, projectID) + if err != nil || project == nil { + return ErrAnalyticsNotFound + } + ok, err := s.ws.IsMember(ctx, project.WorkspaceID, userID) + if err != nil || !ok { + return ErrAnalyticsForbidden } return nil } -func (s *analyticsService) GetWorkspaceAnalytics(userID string, slug string) (*AnalyticsResponse, error) { - if err := s.checkWorkspaceAccess(userID, slug); err != nil { +func (s *analyticsService) GetWorkspaceAnalytics(ctx context.Context, userID uuid.UUID, slug string) (*AnalyticsResponse, error) { + if err := s.ensureWorkspaceAccess(ctx, userID, slug); err != nil { return nil, err } byState := make(map[string]int64) - if stateResults, err := s.store.GetWorkspaceStateAnalytics(slug); err == nil { + if stateResults, err := s.store.GetWorkspaceStateAnalytics(ctx, slug); err == nil { for _, r := range stateResults { byState[r.State] = r.Count } } else { - return nil, err + return nil, fmt.Errorf("fetch workspace state analytics: %w", err) } byPriority := make(map[string]int64) - if priorityResults, err := s.store.GetWorkspacePriorityAnalytics(slug); err == nil { + if priorityResults, err := s.store.GetWorkspacePriorityAnalytics(ctx, slug); err == nil { for _, r := range priorityResults { byPriority[r.Priority] = r.Count } } else { - return nil, err + return nil, fmt.Errorf("fetch workspace priority analytics: %w", err) } byAssignee := make(map[string]int64) - if assigneeResults, err := s.store.GetWorkspaceAssigneeAnalytics(slug); err == nil { + if assigneeResults, err := s.store.GetWorkspaceAssigneeAnalytics(ctx, slug); err == nil { for _, r := range assigneeResults { byAssignee[r.Email] = r.Count } - } else { - s.log.Warn("failed to fetch workspace assignee analytics", "error", err) + } else if s.log != nil { + s.log.Warn("failed to fetch workspace assignee analytics", "error", err, "slug", slug) } byLabel := make(map[string]int64) - if labelResults, err := s.store.GetWorkspaceLabelAnalytics(slug); err == nil { + if labelResults, err := s.store.GetWorkspaceLabelAnalytics(ctx, slug); err == nil { for _, r := range labelResults { byLabel[r.Label] = r.Count } - } else { - s.log.Warn("failed to fetch workspace label analytics", "error", err) + } else if s.log != nil { + s.log.Warn("failed to fetch workspace label analytics", "error", err, "slug", slug) } return &AnalyticsResponse{ @@ -101,36 +111,36 @@ func (s *analyticsService) GetWorkspaceAnalytics(userID string, slug string) (*A }, nil } -func (s *analyticsService) GetProjectAnalytics(userID string, projectID string) (*AnalyticsResponse, error) { - if err := s.checkProjectAccess(userID, projectID); err != nil { +func (s *analyticsService) GetProjectAnalytics(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (*AnalyticsResponse, error) { + if err := s.ensureProjectAccess(ctx, userID, projectID); err != nil { return nil, err } byState := make(map[string]int64) - if stateResults, err := s.store.GetProjectStateAnalytics(projectID); err == nil { + if stateResults, err := s.store.GetProjectStateAnalytics(ctx, projectID); err == nil { for _, r := range stateResults { byState[r.State] = r.Count } } else { - return nil, err + return nil, fmt.Errorf("fetch project state analytics: %w", err) } byPriority := make(map[string]int64) - if priorityResults, err := s.store.GetProjectPriorityAnalytics(projectID); err == nil { + if priorityResults, err := s.store.GetProjectPriorityAnalytics(ctx, projectID); err == nil { for _, r := range priorityResults { byPriority[r.Priority] = r.Count } } byAssignee := make(map[string]int64) - if assigneeResults, err := s.store.GetProjectAssigneeAnalytics(projectID); err == nil { + if assigneeResults, err := s.store.GetProjectAssigneeAnalytics(ctx, projectID); err == nil { for _, r := range assigneeResults { byAssignee[r.Email] = r.Count } } byLabel := make(map[string]int64) - if labelResults, err := s.store.GetProjectLabelAnalytics(projectID); err == nil { + if labelResults, err := s.store.GetProjectLabelAnalytics(ctx, projectID); err == nil { for _, r := range labelResults { byLabel[r.Label] = r.Count } @@ -144,16 +154,16 @@ func (s *analyticsService) GetProjectAnalytics(userID string, projectID string) }, nil } -func (s *analyticsService) ExportWorkspaceCSV(userID string, slug string) ([]WorkspaceIssueExport, error) { - if err := s.checkWorkspaceAccess(userID, slug); err != nil { +func (s *analyticsService) ExportWorkspaceCSV(ctx context.Context, userID uuid.UUID, slug string) ([]WorkspaceIssueExport, error) { + if err := s.ensureWorkspaceAccess(ctx, userID, slug); err != nil { return nil, err } - return s.store.GetWorkspaceIssuesForExport(slug) + return s.store.GetWorkspaceIssuesForExport(ctx, slug) } -func (s *analyticsService) ExportProjectCSV(userID string, projectID string) ([]ProjectIssueExport, error) { - if err := s.checkProjectAccess(userID, projectID); err != nil { +func (s *analyticsService) ExportProjectCSV(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) ([]ProjectIssueExport, error) { + if err := s.ensureProjectAccess(ctx, userID, projectID); err != nil { return nil, err } - return s.store.GetProjectIssuesForExport(projectID) + return s.store.GetProjectIssuesForExport(ctx, projectID) } diff --git a/apps/api/internal/store/analytics.go b/apps/api/internal/store/analytics.go index e69de29..d67a091 100644 --- a/apps/api/internal/store/analytics.go +++ b/apps/api/internal/store/analytics.go @@ -0,0 +1,5 @@ +package store + +import "gorm.io/gorm" + +type AnalyticsStore struct{ db *gorm.DB } From 68ae41d6e2a40138455b4ab77968d502f32987e1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 11:41:03 -0400 Subject: [PATCH 16/28] Added the store and builded the model off of that --- apps/api/internal/model/analytics.go | 34 +++++++ apps/api/internal/store/analytics.go | 145 ++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 apps/api/internal/model/analytics.go diff --git a/apps/api/internal/model/analytics.go b/apps/api/internal/model/analytics.go new file mode 100644 index 0000000..e3975cd --- /dev/null +++ b/apps/api/internal/model/analytics.go @@ -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"` +} diff --git a/apps/api/internal/store/analytics.go b/apps/api/internal/store/analytics.go index d67a091..0e29f68 100644 --- a/apps/api/internal/store/analytics.go +++ b/apps/api/internal/store/analytics.go @@ -1,5 +1,146 @@ package store -import "gorm.io/gorm" +import ( + "context" -type AnalyticsStore struct{ db *gorm.DB } + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type AnalyticsStore struct { + db *gorm.DB +} + +func NewAnalyticsStore(db *gorm.DB) *AnalyticsStore { + return &AnalyticsStore{db: db} +} + +func (s *AnalyticsStore) GetWorkspaceStateAnalytics(ctx context.Context, slug string) ([]model.StateCount, error) { + var results []model.StateCount + err := s.db.WithContext(ctx). + Table("issues"). + Select("issues.state, COUNT(*) as count"). + Joins("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("issues.state"). + Scan(&results).Error + + return results, err +} + +func (s *AnalyticsStore) GetWorkspacePriorityAnalytics(ctx context.Context, slug string) ([]model.PriorityCount, error) { + var results []model.PriorityCount + err := s.db.WithContext(ctx). + Table("issues"). + Select("issues.priority, COUNT(*) as count"). + Joins("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("issues.priority"). + Scan(&results).Error + + return results, err +} + +func (s *AnalyticsStore) GetWorkspaceAssigneeAnalytics(ctx context.Context, slug string) ([]model.AssigneeCount, error) { + var results []model.AssigneeCount + err := s.db.WithContext(ctx). + Table("issues"). + Select("users.email, COUNT(*) as count"). + Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). + Joins("JOIN users ON issues.assignee_id = users.id"). + Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL", slug). + Group("users.email"). + Scan(&results).Error + + return results, err +} + +func (s *AnalyticsStore) GetWorkspaceLabelAnalytics(ctx context.Context, slug string) ([]model.LabelCount, error) { + var results []model.LabelCount + err := s.db.WithContext(ctx). + Table("issue_labels"). + Select("labels.name as label, COUNT(*) as count"). + Joins("JOIN issues ON issue_labels.issue_id = issues.id"). + Joins("JOIN labels ON issue_labels.label_id = labels.id"). + Joins("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("labels.name"). + Scan(&results).Error + + return results, err +} + +func (s *AnalyticsStore) GetProjectStateAnalytics(ctx context.Context, projectID uuid.UUID) ([]model.StateCount, error) { + var results []model.StateCount + err := s.db.WithContext(ctx). + Table("issues"). + Select("state, COUNT(*) as count"). + Where("project_id = ? AND deleted_at IS NULL", projectID). + Group("state"). + Scan(&results).Error + + return results, err +} + +func (s *AnalyticsStore) GetProjectPriorityAnalytics(ctx context.Context, projectID uuid.UUID) ([]model.PriorityCount, error) { + var results []model.PriorityCount + err := s.db.WithContext(ctx). + Table("issues"). + Select("priority, COUNT(*) as count"). + Where("project_id = ? AND deleted_at IS NULL", projectID). + Group("priority"). + Scan(&results).Error + + return results, err +} + +func (s *AnalyticsStore) GetProjectAssigneeAnalytics(ctx context.Context, projectID uuid.UUID) ([]model.AssigneeCount, error) { + var results []model.AssigneeCount + err := s.db.WithContext(ctx). + Table("issues"). + Select("users.email, COUNT(*) as count"). + Joins("JOIN users ON issues.assignee_id = users.id"). + Where("issues.project_id = ? AND issues.deleted_at IS NULL", projectID). + Group("users.email"). + Scan(&results).Error + + return results, err +} + +func (s *AnalyticsStore) GetProjectLabelAnalytics(ctx context.Context, projectID uuid.UUID) ([]model.LabelCount, error) { + var results []model.LabelCount + err := s.db.WithContext(ctx). + Table("issue_labels"). + Select("labels.name as label, COUNT(*) as count"). + Joins("JOIN issues ON issue_labels.issue_id = issues.id"). + Joins("JOIN labels ON issue_labels.label_id = labels.id"). + Where("issues.project_id = ? AND issues.deleted_at IS NULL", projectID). + Group("labels.name"). + Scan(&results).Error + + return results, err +} + +func (s *AnalyticsStore) GetWorkspaceIssuesForExport(ctx context.Context, slug string) ([]model.WorkspaceIssueExport, error) { + var exports []model.WorkspaceIssueExport + err := s.db.WithContext(ctx). + Table("issues"). + Select("issues.id, issues.name, issues.state, issues.priority"). + Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). + Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL", slug). + Scan(&exports).Error + + return exports, err +} + +func (s *AnalyticsStore) GetProjectIssuesForExport(ctx context.Context, projectID uuid.UUID) ([]model.ProjectIssueExport, error) { + var exports []model.ProjectIssueExport + err := s.db.WithContext(ctx). + Table("issues"). + Select("issues.id, issues.name, issues.state"). + Where("project_id = ? AND deleted_at IS NULL", projectID). + Scan(&exports).Error + + return exports, err +} From 3427bae737e0ff3db56037e3ad7c40519af3832c Mon Sep 17 00:00:00 2001 From: LADsy8 <113937809+LADsy8@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:05:33 -0400 Subject: [PATCH 17/28] Update apps/api/internal/handler/analytics.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/api/internal/handler/analytics.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/internal/handler/analytics.go b/apps/api/internal/handler/analytics.go index aebf6ec..698cd95 100644 --- a/apps/api/internal/handler/analytics.go +++ b/apps/api/internal/handler/analytics.go @@ -129,7 +129,8 @@ func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { return } - filename := fmt.Sprintf("workspace-%s-analytics-%s.csv", slug, time.Now().Format("2006-01-02")) + safeSlug := strings.ReplaceAll(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") From 96cc86f2de86baa9f7b08e442b3546669a1793cd Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 12:10:42 -0400 Subject: [PATCH 18/28] refactored the service to use the good errors --- apps/api/internal/handler/analytics.go | 2 +- apps/api/internal/router/router.go | 7 +- apps/api/internal/service/analytics.go | 111 +++++++++++++------------ 3 files changed, 62 insertions(+), 58 deletions(-) diff --git a/apps/api/internal/handler/analytics.go b/apps/api/internal/handler/analytics.go index aebf6ec..226784c 100644 --- a/apps/api/internal/handler/analytics.go +++ b/apps/api/internal/handler/analytics.go @@ -15,7 +15,7 @@ import ( ) type AnalyticsHandler struct { - AnalyticsService service.AnalyticsService + AnalyticsService *service.AnalyticsService Log *slog.Logger } diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 1063f61..09c51d2 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -245,13 +245,8 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { AppBaseURL: appBaseURL, } - // Store initialization analyticsStore := store.NewAnalyticsStore(cfg.DB) - - // Service initialization - analyticsSvc := service.NewAnalyticsService(analyticsStore, cfg.Log, cfg.DB) - - // Handler initialization + analyticsSvc := service.NewAnalyticsService(analyticsStore, workspaceStore, projectStore, cfg.Log) analyticsHandler := &handler.AnalyticsHandler{ AnalyticsService: analyticsSvc, Log: cfg.Log, diff --git a/apps/api/internal/service/analytics.go b/apps/api/internal/service/analytics.go index 5c227bc..6e6a7c1 100644 --- a/apps/api/internal/service/analytics.go +++ b/apps/api/internal/service/analytics.go @@ -6,69 +6,78 @@ import ( "fmt" "log/slog" + "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/store" "github.com/google/uuid" + "gorm.io/gorm" ) -var ( - ErrAnalyticsForbidden = errors.New("unauthorized workspace/project access") - ErrAnalyticsNotFound = errors.New("workspace or project not found") -) - -type AnalyticsService interface { - GetWorkspaceAnalytics(ctx context.Context, userID uuid.UUID, slug string) (*AnalyticsResponse, error) - GetProjectAnalytics(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (*AnalyticsResponse, error) - ExportWorkspaceCSV(ctx context.Context, userID uuid.UUID, slug string) ([]WorkspaceIssueExport, error) - ExportProjectCSV(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) ([]ProjectIssueExport, error) +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"` } -type analyticsService struct { - store store.AnalyticsStore - ws *store.WorkspaceStore - ps *store.ProjectStore - log *slog.Logger +type AnalyticsService struct { + as *store.AnalyticsStore + ws *store.WorkspaceStore + ps *store.ProjectStore + log *slog.Logger } -func NewAnalyticsService(store store.AnalyticsStore, ws *store.WorkspaceStore, ps *store.ProjectStore, log *slog.Logger) AnalyticsService { - return &analyticsService{ - store: store, - ws: ws, - ps: ps, - log: log, +func NewAnalyticsService(as *store.AnalyticsStore, ws *store.WorkspaceStore, ps *store.ProjectStore, log *slog.Logger) *AnalyticsService { + return &AnalyticsService{ + as: as, + ws: ws, + ps: ps, + log: log, } } -func (s *analyticsService) ensureWorkspaceAccess(ctx context.Context, userID uuid.UUID, slug string) error { +func (s *AnalyticsService) ensureWorkspaceAccess(ctx context.Context, slug string, userID uuid.UUID) (*model.Workspace, error) { wrk, err := s.ws.GetBySlug(ctx, slug) - if err != nil || wrk == nil { - return ErrAnalyticsNotFound + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrWorkspaceNotFound + } + return nil, err } ok, err := s.ws.IsMember(ctx, wrk.ID, userID) - if err != nil || !ok { - return ErrAnalyticsForbidden + if err != nil { + return nil, err } - return nil + if !ok { + return nil, ErrWorkspaceForbidden + } + return wrk, nil } -func (s *analyticsService) ensureProjectAccess(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) error { +func (s *AnalyticsService) ensureProjectAccess(ctx context.Context, projectID, userID uuid.UUID) (*model.Project, error) { project, err := s.ps.GetByID(ctx, projectID) - if err != nil || project == nil { - return ErrAnalyticsNotFound + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrProjectNotFound + } + return nil, err } ok, err := s.ws.IsMember(ctx, project.WorkspaceID, userID) - if err != nil || !ok { - return ErrAnalyticsForbidden + if err != nil { + return nil, err + } + if !ok { + return nil, ErrProjectForbidden } - return nil + return project, nil } -func (s *analyticsService) GetWorkspaceAnalytics(ctx context.Context, userID uuid.UUID, slug string) (*AnalyticsResponse, error) { - if err := s.ensureWorkspaceAccess(ctx, userID, slug); err != nil { +func (s *AnalyticsService) GetWorkspaceAnalytics(ctx context.Context, slug string, userID uuid.UUID) (*AnalyticsResponse, error) { + if _, err := s.ensureWorkspaceAccess(ctx, slug, userID); err != nil { return nil, err } byState := make(map[string]int64) - if stateResults, err := s.store.GetWorkspaceStateAnalytics(ctx, slug); err == nil { + if stateResults, err := s.as.GetWorkspaceStateAnalytics(ctx, slug); err == nil { for _, r := range stateResults { byState[r.State] = r.Count } @@ -77,7 +86,7 @@ func (s *analyticsService) GetWorkspaceAnalytics(ctx context.Context, userID uui } byPriority := make(map[string]int64) - if priorityResults, err := s.store.GetWorkspacePriorityAnalytics(ctx, slug); err == nil { + if priorityResults, err := s.as.GetWorkspacePriorityAnalytics(ctx, slug); err == nil { for _, r := range priorityResults { byPriority[r.Priority] = r.Count } @@ -86,7 +95,7 @@ func (s *analyticsService) GetWorkspaceAnalytics(ctx context.Context, userID uui } byAssignee := make(map[string]int64) - if assigneeResults, err := s.store.GetWorkspaceAssigneeAnalytics(ctx, slug); err == nil { + if assigneeResults, err := s.as.GetWorkspaceAssigneeAnalytics(ctx, slug); err == nil { for _, r := range assigneeResults { byAssignee[r.Email] = r.Count } @@ -95,7 +104,7 @@ func (s *analyticsService) GetWorkspaceAnalytics(ctx context.Context, userID uui } byLabel := make(map[string]int64) - if labelResults, err := s.store.GetWorkspaceLabelAnalytics(ctx, slug); err == nil { + if labelResults, err := s.as.GetWorkspaceLabelAnalytics(ctx, slug); err == nil { for _, r := range labelResults { byLabel[r.Label] = r.Count } @@ -111,13 +120,13 @@ func (s *analyticsService) GetWorkspaceAnalytics(ctx context.Context, userID uui }, nil } -func (s *analyticsService) GetProjectAnalytics(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (*AnalyticsResponse, error) { - if err := s.ensureProjectAccess(ctx, userID, projectID); err != nil { +func (s *AnalyticsService) GetProjectAnalytics(ctx context.Context, projectID, userID uuid.UUID) (*AnalyticsResponse, error) { + if _, err := s.ensureProjectAccess(ctx, projectID, userID); err != nil { return nil, err } byState := make(map[string]int64) - if stateResults, err := s.store.GetProjectStateAnalytics(ctx, projectID); err == nil { + if stateResults, err := s.as.GetProjectStateAnalytics(ctx, projectID); err == nil { for _, r := range stateResults { byState[r.State] = r.Count } @@ -126,21 +135,21 @@ func (s *analyticsService) GetProjectAnalytics(ctx context.Context, userID uuid. } byPriority := make(map[string]int64) - if priorityResults, err := s.store.GetProjectPriorityAnalytics(ctx, projectID); err == nil { + if priorityResults, err := s.as.GetProjectPriorityAnalytics(ctx, projectID); err == nil { for _, r := range priorityResults { byPriority[r.Priority] = r.Count } } byAssignee := make(map[string]int64) - if assigneeResults, err := s.store.GetProjectAssigneeAnalytics(ctx, projectID); err == nil { + if assigneeResults, err := s.as.GetProjectAssigneeAnalytics(ctx, projectID); err == nil { for _, r := range assigneeResults { byAssignee[r.Email] = r.Count } } byLabel := make(map[string]int64) - if labelResults, err := s.store.GetProjectLabelAnalytics(ctx, projectID); err == nil { + if labelResults, err := s.as.GetProjectLabelAnalytics(ctx, projectID); err == nil { for _, r := range labelResults { byLabel[r.Label] = r.Count } @@ -154,16 +163,16 @@ func (s *analyticsService) GetProjectAnalytics(ctx context.Context, userID uuid. }, nil } -func (s *analyticsService) ExportWorkspaceCSV(ctx context.Context, userID uuid.UUID, slug string) ([]WorkspaceIssueExport, error) { - if err := s.ensureWorkspaceAccess(ctx, userID, slug); err != nil { +func (s *AnalyticsService) ExportWorkspaceCSV(ctx context.Context, slug string, userID uuid.UUID) ([]model.WorkspaceIssueExport, error) { + if _, err := s.ensureWorkspaceAccess(ctx, slug, userID); err != nil { return nil, err } - return s.store.GetWorkspaceIssuesForExport(ctx, slug) + return s.as.GetWorkspaceIssuesForExport(ctx, slug) } -func (s *analyticsService) ExportProjectCSV(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) ([]ProjectIssueExport, error) { - if err := s.ensureProjectAccess(ctx, userID, projectID); err != nil { +func (s *AnalyticsService) ExportProjectCSV(ctx context.Context, projectID, userID uuid.UUID) ([]model.ProjectIssueExport, error) { + if _, err := s.ensureProjectAccess(ctx, projectID, userID); err != nil { return nil, err } - return s.store.GetProjectIssuesForExport(ctx, projectID) + return s.as.GetProjectIssuesForExport(ctx, projectID) } From 365219061f6c149de1e189a2d6b6131ba0d90e98 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 12:18:33 -0400 Subject: [PATCH 19/28] followed the position of parameters from the service --- apps/api/internal/handler/analytics.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/api/internal/handler/analytics.go b/apps/api/internal/handler/analytics.go index 1c17d1a..0d83de5 100644 --- a/apps/api/internal/handler/analytics.go +++ b/apps/api/internal/handler/analytics.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "time" "github.com/Devlaner/devlane/api/internal/middleware" @@ -53,13 +54,13 @@ func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) { return } - res, err := h.AnalyticsService.GetWorkspaceAnalytics(c.Request.Context(), user.ID, slug) + res, err := h.AnalyticsService.GetWorkspaceAnalytics(c.Request.Context(), slug, user.ID) if err != nil { - if errors.Is(err, service.ErrAnalyticsForbidden) { + if errors.Is(err, service.ErrWorkspaceForbidden) { c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) return } - if errors.Is(err, service.ErrAnalyticsNotFound) { + if errors.Is(err, service.ErrWorkspaceNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"}) return } @@ -88,13 +89,13 @@ func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { return } - res, err := h.AnalyticsService.GetProjectAnalytics(c.Request.Context(), user.ID, projectID) + res, err := h.AnalyticsService.GetProjectAnalytics(c.Request.Context(), projectID, user.ID) if err != nil { - if errors.Is(err, service.ErrAnalyticsForbidden) { + if errors.Is(err, service.ErrWorkspaceForbidden) { c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) return } - if errors.Is(err, service.ErrAnalyticsNotFound) { + if errors.Is(err, service.ErrWorkspaceNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } @@ -114,13 +115,13 @@ func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { return } - issues, err := h.AnalyticsService.ExportWorkspaceCSV(c.Request.Context(), user.ID, slug) + issues, err := h.AnalyticsService.ExportWorkspaceCSV(c.Request.Context(), slug, user.ID) if err != nil { - if errors.Is(err, service.ErrAnalyticsForbidden) { + if errors.Is(err, service.ErrWorkspaceForbidden) { c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) return } - if errors.Is(err, service.ErrAnalyticsNotFound) { + if errors.Is(err, service.ErrWorkspaceNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"}) return } @@ -165,13 +166,13 @@ func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { return } - issues, err := h.AnalyticsService.ExportProjectCSV(c.Request.Context(), user.ID, projectID) + issues, err := h.AnalyticsService.ExportProjectCSV(c.Request.Context(), projectID, user.ID) if err != nil { - if errors.Is(err, service.ErrAnalyticsForbidden) { + if errors.Is(err, service.ErrWorkspaceForbidden) { c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) return } - if errors.Is(err, service.ErrAnalyticsNotFound) { + if errors.Is(err, service.ErrWorkspaceNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } From a439186026e10f97ee4b22b979c88fc94c6161dd Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 12:25:07 -0400 Subject: [PATCH 20/28] Runned prettier --- apps/web/src/pages/AnalyticsWorkItemsPage.tsx | 22 ++++++++++++------- apps/web/src/pages/IssueListPage.tsx | 16 +++++++------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx index 68bf69a..a3dec62 100644 --- a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx +++ b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx @@ -186,9 +186,7 @@ export function AnalyticsWorkItemsPage() { setAnalytics(analyticsResult.value); } else { console.error('Erreur API Analytics Go:', analyticsResult.reason); - setAnalyticsError( - t('analytics.loadFailed', 'Could not load analytics. Please try again.'), - ); + setAnalyticsError(t('analytics.loadFailed', 'Could not load analytics. Please try again.')); } })().finally(() => { if (!cancelled) setLoading(false); @@ -295,7 +293,9 @@ export function AnalyticsWorkItemsPage() { {/* KPIs */}
-

{t('analytics.totalWorkItems', 'Total Work items')}

+

+ {t('analytics.totalWorkItems', 'Total Work items')} +

{totalIssues}

@@ -484,7 +484,9 @@ export function AnalyticsWorkItemsPage() {

- {t('analytics.prioritiesCount', '{{count}} Priorities', { count: priorityRows.length })} + {t('analytics.prioritiesCount', '{{count}} Priorities', { + count: priorityRows.length, + })}

- - + + @@ -572,4 +578,4 @@ export function AnalyticsWorkItemsPage() { ); -} \ No newline at end of file +} diff --git a/apps/web/src/pages/IssueListPage.tsx b/apps/web/src/pages/IssueListPage.tsx index d060f7c..3312242 100644 --- a/apps/web/src/pages/IssueListPage.tsx +++ b/apps/web/src/pages/IssueListPage.tsx @@ -183,14 +183,14 @@ export function IssueListPage() { setLoading(true); const safeFetch = (promise: Promise, fallback: T): Promise => { - return Promise.resolve(promise) - .then((val) => val ?? fallback) - .catch((err: unknown) => { - const errorMessage = err instanceof Error ? err.message : String(err); - console.warn('Secondary request ignored (Prevents crashes)', errorMessage); - return fallback; - }); -}; + return Promise.resolve(promise) + .then((val) => val ?? fallback) + .catch((err: unknown) => { + const errorMessage = err instanceof Error ? err.message : String(err); + console.warn('Secondary request ignored (Prevents crashes)', errorMessage); + return fallback; + }); + }; Promise.all([ workspaceService.getBySlug(workspaceSlug), From d3ee76a89699f6c85adf77200fbb5c1356342f6f Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 12:39:01 -0400 Subject: [PATCH 21/28] joins issues and project tables and return error when getting --- apps/api/internal/service/analytics.go | 6 ++++++ apps/api/internal/store/analytics.go | 15 ++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/api/internal/service/analytics.go b/apps/api/internal/service/analytics.go index 6e6a7c1..043c431 100644 --- a/apps/api/internal/service/analytics.go +++ b/apps/api/internal/service/analytics.go @@ -139,6 +139,8 @@ func (s *AnalyticsService) GetProjectAnalytics(ctx context.Context, projectID, u for _, r := range priorityResults { byPriority[r.Priority] = r.Count } + } else { + return nil, fmt.Errorf("fetch project priority analytics: %w", err) } byAssignee := make(map[string]int64) @@ -146,6 +148,8 @@ func (s *AnalyticsService) GetProjectAnalytics(ctx context.Context, projectID, u for _, r := range assigneeResults { byAssignee[r.Email] = r.Count } + } else if s.log != nil { + s.log.Warn("failed to fetch project assignee analytics", "error", err, "project_id", projectID) } byLabel := make(map[string]int64) @@ -153,6 +157,8 @@ func (s *AnalyticsService) GetProjectAnalytics(ctx context.Context, projectID, u for _, r := range labelResults { byLabel[r.Label] = r.Count } + } else if s.log != nil { + s.log.Warn("failed to fetch project label analytics", "error", err, "project_id", projectID) } return &AnalyticsResponse{ diff --git a/apps/api/internal/store/analytics.go b/apps/api/internal/store/analytics.go index 0e29f68..9f02912 100644 --- a/apps/api/internal/store/analytics.go +++ b/apps/api/internal/store/analytics.go @@ -22,7 +22,8 @@ func (s *AnalyticsStore) GetWorkspaceStateAnalytics(ctx context.Context, slug st Table("issues"). Select("issues.state, COUNT(*) as count"). Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). - Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL", slug). + Joins("JOIN projects ON issues.project_id = projects.id"). + Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). Group("issues.state"). Scan(&results).Error @@ -35,7 +36,8 @@ func (s *AnalyticsStore) GetWorkspacePriorityAnalytics(ctx context.Context, slug Table("issues"). Select("issues.priority, COUNT(*) as count"). Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). - Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL", slug). + Joins("JOIN projects ON issues.project_id = projects.id"). + Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). Group("issues.priority"). Scan(&results).Error @@ -48,8 +50,9 @@ func (s *AnalyticsStore) GetWorkspaceAssigneeAnalytics(ctx context.Context, slug Table("issues"). Select("users.email, COUNT(*) as count"). Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). + Joins("JOIN projects ON issues.project_id = projects.id"). Joins("JOIN users ON issues.assignee_id = users.id"). - Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL", slug). + Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). Group("users.email"). Scan(&results).Error @@ -64,7 +67,8 @@ func (s *AnalyticsStore) GetWorkspaceLabelAnalytics(ctx context.Context, slug st Joins("JOIN issues ON issue_labels.issue_id = issues.id"). Joins("JOIN labels ON issue_labels.label_id = labels.id"). Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). - Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL", slug). + Joins("JOIN projects ON issues.project_id = projects.id"). + Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). Group("labels.name"). Scan(&results).Error @@ -128,7 +132,8 @@ func (s *AnalyticsStore) GetWorkspaceIssuesForExport(ctx context.Context, slug s Table("issues"). Select("issues.id, issues.name, issues.state, issues.priority"). Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). - Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL", slug). + Joins("JOIN projects ON issues.project_id = projects.id"). + Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). Scan(&exports).Error return exports, err From df956190799c7baeb56d727e4453bbb0d0add906 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 12:42:59 -0400 Subject: [PATCH 22/28] correcting handler with safeguards and sanitization --- apps/api/internal/handler/analytics.go | 46 +++++++++++++++----------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/apps/api/internal/handler/analytics.go b/apps/api/internal/handler/analytics.go index 0d83de5..a30cfbe 100644 --- a/apps/api/internal/handler/analytics.go +++ b/apps/api/internal/handler/analytics.go @@ -32,15 +32,23 @@ func sanitizeCSVField(v string) string { } } +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 UUID parameter"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) return uuid.Nil, false } return parsed, true @@ -73,13 +81,8 @@ func (h *AnalyticsHandler) GetWorkspaceAnalytics(c *gin.Context) { } func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { - projectParam := c.Param("projectId") - if projectParam == "" { - projectParam = c.Param("projectID") - } - projectID, err := uuid.Parse(projectParam) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) + projectID, ok := parseParamUUID(c, "projectId") + if !ok { return } @@ -91,11 +94,11 @@ func (h *AnalyticsHandler) GetProjectAnalytics(c *gin.Context) { res, err := h.AnalyticsService.GetProjectAnalytics(c.Request.Context(), projectID, user.ID) if err != nil { - if errors.Is(err, service.ErrWorkspaceForbidden) { + 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.ErrWorkspaceNotFound) { + if errors.Is(err, service.ErrProjectNotFound) || errors.Is(err, service.ErrWorkspaceNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } @@ -130,7 +133,7 @@ func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { return } - safeSlug := strings.ReplaceAll(slug, `"`, "") + 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") @@ -146,17 +149,16 @@ func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { 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) { - projectParam := c.Param("projectId") - if projectParam == "" { - projectParam = c.Param("projectID") - } - projectID, err := uuid.Parse(projectParam) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) + projectID, ok := parseParamUUID(c, "projectId") + if !ok { return } @@ -168,11 +170,11 @@ func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { issues, err := h.AnalyticsService.ExportProjectCSV(c.Request.Context(), projectID, user.ID) if err != nil { - if errors.Is(err, service.ErrWorkspaceForbidden) { + 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.ErrWorkspaceNotFound) { + if errors.Is(err, service.ErrProjectNotFound) || errors.Is(err, service.ErrWorkspaceNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } @@ -195,5 +197,9 @@ func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { sanitizeCSVField(issue.State), }) } + writer.Flush() + if err := writer.Error(); err != nil { + h.Log.Error("failed to flush CSV writer", "error", err, "project_id", projectID) + } } From 41e5ac32c6ed653dae53e985930dccb1ff2f3edf Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 12:52:43 -0400 Subject: [PATCH 23/28] better err handling again --- apps/api/internal/service/analytics.go | 4 +- apps/api/internal/store/analytics.go | 52 +++++++++++++++++++------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/apps/api/internal/service/analytics.go b/apps/api/internal/service/analytics.go index 043c431..ba6a61e 100644 --- a/apps/api/internal/service/analytics.go +++ b/apps/api/internal/service/analytics.go @@ -173,12 +173,12 @@ func (s *AnalyticsService) ExportWorkspaceCSV(ctx context.Context, slug string, if _, err := s.ensureWorkspaceAccess(ctx, slug, userID); err != nil { return nil, err } - return s.as.GetWorkspaceIssuesForExport(ctx, slug) + return s.as.StreamWorkspaceIssuesForExport(ctx, slug) } func (s *AnalyticsService) ExportProjectCSV(ctx context.Context, projectID, userID uuid.UUID) ([]model.ProjectIssueExport, error) { if _, err := s.ensureProjectAccess(ctx, projectID, userID); err != nil { return nil, err } - return s.as.GetProjectIssuesForExport(ctx, projectID) + return s.as.StreamProjectIssuesForExport(ctx, projectID) } diff --git a/apps/api/internal/store/analytics.go b/apps/api/internal/store/analytics.go index 9f02912..bfffdce 100644 --- a/apps/api/internal/store/analytics.go +++ b/apps/api/internal/store/analytics.go @@ -126,26 +126,50 @@ func (s *AnalyticsStore) GetProjectLabelAnalytics(ctx context.Context, projectID return results, err } -func (s *AnalyticsStore) GetWorkspaceIssuesForExport(ctx context.Context, slug string) ([]model.WorkspaceIssueExport, error) { - var exports []model.WorkspaceIssueExport - err := s.db.WithContext(ctx). +func (s *AnalyticsStore) StreamWorkspaceIssuesForExport(ctx context.Context, slug string, fn func(model.WorkspaceIssueExport) error) error { + rows, err := s.db.WithContext(ctx). Table("issues"). - Select("issues.id, issues.name, issues.state, issues.priority"). + Select("issues.id AS id, issues.name AS name, issues.state AS state, issues.priority AS priority"). Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). Joins("JOIN projects ON issues.project_id = projects.id"). Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). - Scan(&exports).Error - - return exports, err + Rows() + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var item model.WorkspaceIssueExport + if err := s.db.ScanRows(rows, &item); err != nil { + return err + } + if err := fn(item); err != nil { + return err + } + } + return rows.Err() } -func (s *AnalyticsStore) GetProjectIssuesForExport(ctx context.Context, projectID uuid.UUID) ([]model.ProjectIssueExport, error) { - var exports []model.ProjectIssueExport - err := s.db.WithContext(ctx). +func (s *AnalyticsStore) StreamProjectIssuesForExport(ctx context.Context, projectID uuid.UUID, fn func(model.ProjectIssueExport) error) error { + rows, err := s.db.WithContext(ctx). Table("issues"). - Select("issues.id, issues.name, issues.state"). + Select("issues.id AS id, issues.name AS name, issues.state AS state"). Where("project_id = ? AND deleted_at IS NULL", projectID). - Scan(&exports).Error - - return exports, err + Rows() + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var item model.ProjectIssueExport + if err := s.db.ScanRows(rows, &item); err != nil { + return err + } + if err := fn(item); err != nil { + return err + } + } + return rows.Err() } From c61acc9d91c4a8f1edabd942ba346130395b57d2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 14:20:02 -0400 Subject: [PATCH 24/28] change the exports to work when errors occurs --- apps/api/internal/service/analytics.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/api/internal/service/analytics.go b/apps/api/internal/service/analytics.go index ba6a61e..107cda6 100644 --- a/apps/api/internal/service/analytics.go +++ b/apps/api/internal/service/analytics.go @@ -173,12 +173,32 @@ func (s *AnalyticsService) ExportWorkspaceCSV(ctx context.Context, slug string, if _, err := s.ensureWorkspaceAccess(ctx, slug, userID); err != nil { return nil, err } - return s.as.StreamWorkspaceIssuesForExport(ctx, slug) + + var exports []model.WorkspaceIssueExport + + err := s.as.StreamWorkspaceIssuesForExport(ctx, slug, func(item model.WorkspaceIssueExport) error { + exports = append(exports, item) + return nil + }) + if err != nil { + return nil, err + } + + return exports, nil } func (s *AnalyticsService) ExportProjectCSV(ctx context.Context, projectID, userID uuid.UUID) ([]model.ProjectIssueExport, error) { if _, err := s.ensureProjectAccess(ctx, projectID, userID); err != nil { return nil, err } - return s.as.StreamProjectIssuesForExport(ctx, projectID) + var exports []model.ProjectIssueExport + err := s.as.StreamProjectIssuesForExport(ctx, projectID, func(item model.ProjectIssueExport) error { + exports = append(exports, item) + return nil + }) + if err != nil { + return nil, err + } + + return exports, nil } From 89325571784c798421fde6b521136b1fbe309f2d Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 15:06:16 -0400 Subject: [PATCH 25/28] added trend data possibilities for projects and workspaces --- apps/api/internal/model/analytics.go | 6 +++++ apps/api/internal/service/analytics.go | 9 ++++---- apps/api/internal/store/analytics.go | 32 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/apps/api/internal/model/analytics.go b/apps/api/internal/model/analytics.go index e3975cd..22548fa 100644 --- a/apps/api/internal/model/analytics.go +++ b/apps/api/internal/model/analytics.go @@ -32,3 +32,9 @@ type ProjectIssueExport struct { Name string `gorm:"column:name" json:"name"` State string `gorm:"column:state" json:"state"` } + +type TrendPoint struct { + Date string `gorm:"column:date" json:"date"` + Created int64 `gorm:"column:created" json:"created"` + Resolved int64 `gorm:"column:resolved" json:"resolved"` +} diff --git a/apps/api/internal/service/analytics.go b/apps/api/internal/service/analytics.go index 107cda6..38ec969 100644 --- a/apps/api/internal/service/analytics.go +++ b/apps/api/internal/service/analytics.go @@ -13,10 +13,11 @@ import ( ) 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"` + 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"` + Trend []model.TrendPoint `json:"trend"` } type AnalyticsService struct { diff --git a/apps/api/internal/store/analytics.go b/apps/api/internal/store/analytics.go index bfffdce..0e0d0de 100644 --- a/apps/api/internal/store/analytics.go +++ b/apps/api/internal/store/analytics.go @@ -173,3 +173,35 @@ func (s *AnalyticsStore) StreamProjectIssuesForExport(ctx context.Context, proje } return rows.Err() } + +func (s *AnalyticsStore) GetWorkspaceTrendAnalytics(ctx context.Context, slug string) ([]model.TrendPoint, error) { + var results []model.TrendPoint + err := s.db.WithContext(ctx). + Table("issues"). + Select("DATE(issues.created_at) AS date, "+ + "COUNT(CASE WHEN issues.created_at IS NOT NULL THEN 1 END) AS created, "+ + "COUNT(CASE WHEN issues.state = 'resolved' THEN 1 END) AS resolved"). + Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). + Joins("JOIN projects ON issues.project_id = projects.id"). + Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). + Group("DATE(issues.created_at)"). + Order("date ASC"). + Scan(&results).Error + + return results, err +} + +func (s *AnalyticsStore) GetProjectTrendAnalytics(ctx context.Context, projectID uuid.UUID) ([]model.TrendPoint, error) { + var results []model.TrendPoint + err := s.db.WithContext(ctx). + Table("issues"). + Select("DATE(created_at) AS date, "+ + "COUNT(CASE WHEN created_at IS NOT NULL THEN 1 END) AS created, "+ + "COUNT(CASE WHEN state = 'resolved' THEN 1 END) AS resolved"). + Where("project_id = ? AND deleted_at IS NULL", projectID). + Group("DATE(created_at)"). + Order("date ASC"). + Scan(&results).Error + + return results, err +} From cfc278125881eea8f6d8024ca0024f6d79364dcc Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 15:16:47 -0400 Subject: [PATCH 26/28] returned errors like any other files, and added trends to the good responses --- apps/api/internal/service/analytics.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/api/internal/service/analytics.go b/apps/api/internal/service/analytics.go index 38ec969..a280fef 100644 --- a/apps/api/internal/service/analytics.go +++ b/apps/api/internal/service/analytics.go @@ -101,7 +101,7 @@ func (s *AnalyticsService) GetWorkspaceAnalytics(ctx context.Context, slug strin byAssignee[r.Email] = r.Count } } else if s.log != nil { - s.log.Warn("failed to fetch workspace assignee analytics", "error", err, "slug", slug) + return nil, fmt.Errorf("fetch workspace assignee analytics: %w", err) } byLabel := make(map[string]int64) @@ -110,7 +110,12 @@ func (s *AnalyticsService) GetWorkspaceAnalytics(ctx context.Context, slug strin byLabel[r.Label] = r.Count } } else if s.log != nil { - s.log.Warn("failed to fetch workspace label analytics", "error", err, "slug", slug) + return nil, fmt.Errorf("fetch workspace labels analytics: %w", err) + } + + trend, err := s.as.GetWorkspaceTrendAnalytics(ctx, slug) + if err != nil { + return nil, fmt.Errorf("fetch workspace trend analytics: %w", err) } return &AnalyticsResponse{ @@ -118,6 +123,7 @@ func (s *AnalyticsService) GetWorkspaceAnalytics(ctx context.Context, slug strin ByPriority: byPriority, ByAssignee: byAssignee, ByLabel: byLabel, + Trend: trend, }, nil } @@ -150,7 +156,7 @@ func (s *AnalyticsService) GetProjectAnalytics(ctx context.Context, projectID, u byAssignee[r.Email] = r.Count } } else if s.log != nil { - s.log.Warn("failed to fetch project assignee analytics", "error", err, "project_id", projectID) + return nil, fmt.Errorf("fetch project assignee analytics: %w", err) } byLabel := make(map[string]int64) @@ -159,7 +165,12 @@ func (s *AnalyticsService) GetProjectAnalytics(ctx context.Context, projectID, u byLabel[r.Label] = r.Count } } else if s.log != nil { - s.log.Warn("failed to fetch project label analytics", "error", err, "project_id", projectID) + return nil, fmt.Errorf("fetch project labels analytics: %w", err) + } + + trend, err := s.as.GetProjectTrendAnalytics(ctx, projectID) + if err != nil { + return nil, fmt.Errorf("fetch project trend analytics: %w", err) } return &AnalyticsResponse{ @@ -167,6 +178,7 @@ func (s *AnalyticsService) GetProjectAnalytics(ctx context.Context, projectID, u ByPriority: byPriority, ByAssignee: byAssignee, ByLabel: byLabel, + Trend: trend, }, nil } From 17a7e0137ae18d8a32dfa018b3f92a3a346c7935 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 15:31:22 -0400 Subject: [PATCH 27/28] added numerous changes to correct coderabbit reported errors --- apps/api/internal/handler/analytics.go | 125 ++++++++++++++----------- apps/api/internal/service/analytics.go | 39 +++----- apps/api/internal/store/analytics.go | 91 ++++++++++++------ 3 files changed, 148 insertions(+), 107 deletions(-) diff --git a/apps/api/internal/handler/analytics.go b/apps/api/internal/handler/analytics.go index a30cfbe..9ba3d9d 100644 --- a/apps/api/internal/handler/analytics.go +++ b/apps/api/internal/handler/analytics.go @@ -10,6 +10,7 @@ import ( "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" @@ -118,41 +119,48 @@ func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { 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"}) + 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"}); err != nil { + return err + } + headerWritten = true + } - for _, issue := range issues { - _ = writer.Write([]string{ + if err := writer.Write([]string{ issue.ID, sanitizeCSVField(issue.Name), sanitizeCSVField(issue.State), sanitizeCSVField(issue.Priority), - }) - } + }); err != nil { + return err + } + + writer.Flush() + return writer.Error() + }) - writer.Flush() - if err := writer.Error(); err != nil { - h.Log.Error("failed to flush CSV writer", "error", err, "slug", slug) + 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) } } @@ -168,38 +176,45 @@ func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { 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"}) + 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"}); err != nil { + return err + } + headerWritten = true + } - for _, issue := range issues { - _ = writer.Write([]string{ + if err := writer.Write([]string{ issue.ID, sanitizeCSVField(issue.Name), sanitizeCSVField(issue.State), - }) - } + }); err != nil { + return err + } + + writer.Flush() + return writer.Error() + }) - writer.Flush() - if err := writer.Error(); err != nil { - h.Log.Error("failed to flush CSV writer", "error", err, "project_id", projectID) + 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) } } diff --git a/apps/api/internal/service/analytics.go b/apps/api/internal/service/analytics.go index a280fef..c5c9be8 100644 --- a/apps/api/internal/service/analytics.go +++ b/apps/api/internal/service/analytics.go @@ -182,36 +182,27 @@ func (s *AnalyticsService) GetProjectAnalytics(ctx context.Context, projectID, u }, nil } -func (s *AnalyticsService) ExportWorkspaceCSV(ctx context.Context, slug string, userID uuid.UUID) ([]model.WorkspaceIssueExport, error) { +func (s *AnalyticsService) ExportWorkspaceCSV( + ctx context.Context, + slug string, + userID uuid.UUID, + fn func(model.WorkspaceIssueExport) error, +) error { if _, err := s.ensureWorkspaceAccess(ctx, slug, userID); err != nil { - return nil, err - } - - var exports []model.WorkspaceIssueExport - - err := s.as.StreamWorkspaceIssuesForExport(ctx, slug, func(item model.WorkspaceIssueExport) error { - exports = append(exports, item) - return nil - }) - if err != nil { - return nil, err + return err } - return exports, nil + return s.as.StreamWorkspaceIssuesForExport(ctx, slug, fn) } -func (s *AnalyticsService) ExportProjectCSV(ctx context.Context, projectID, userID uuid.UUID) ([]model.ProjectIssueExport, error) { +func (s *AnalyticsService) ExportProjectCSV( + ctx context.Context, + projectID, userID uuid.UUID, + fn func(model.ProjectIssueExport) error, +) error { if _, err := s.ensureProjectAccess(ctx, projectID, userID); err != nil { - return nil, err - } - var exports []model.ProjectIssueExport - err := s.as.StreamProjectIssuesForExport(ctx, projectID, func(item model.ProjectIssueExport) error { - exports = append(exports, item) - return nil - }) - if err != nil { - return nil, err + return err } - return exports, nil + return s.as.StreamProjectIssuesForExport(ctx, projectID, fn) } diff --git a/apps/api/internal/store/analytics.go b/apps/api/internal/store/analytics.go index 0e0d0de..57f4dd2 100644 --- a/apps/api/internal/store/analytics.go +++ b/apps/api/internal/store/analytics.go @@ -48,12 +48,12 @@ func (s *AnalyticsStore) GetWorkspaceAssigneeAnalytics(ctx context.Context, slug var results []model.AssigneeCount err := s.db.WithContext(ctx). Table("issues"). - Select("users.email, COUNT(*) as count"). + Select("COALESCE(users.email, 'Unassigned') AS email, COUNT(*) as count"). Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). Joins("JOIN projects ON issues.project_id = projects.id"). - Joins("JOIN users ON issues.assignee_id = users.id"). + Joins("LEFT JOIN users ON issues.assignee_id = users.id"). Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). - Group("users.email"). + Group("COALESCE(users.email, 'Unassigned')"). Scan(&results).Error return results, err @@ -103,10 +103,10 @@ func (s *AnalyticsStore) GetProjectAssigneeAnalytics(ctx context.Context, projec var results []model.AssigneeCount err := s.db.WithContext(ctx). Table("issues"). - Select("users.email, COUNT(*) as count"). - Joins("JOIN users ON issues.assignee_id = users.id"). + Select("COALESCE(users.email, 'Unassigned') AS email, COUNT(*) as count"). + Joins("LEFT JOIN users ON issues.assignee_id = users.id"). Where("issues.project_id = ? AND issues.deleted_at IS NULL", projectID). - Group("users.email"). + Group("COALESCE(users.email, 'Unassigned')"). Scan(&results).Error return results, err @@ -176,32 +176,67 @@ func (s *AnalyticsStore) StreamProjectIssuesForExport(ctx context.Context, proje func (s *AnalyticsStore) GetWorkspaceTrendAnalytics(ctx context.Context, slug string) ([]model.TrendPoint, error) { var results []model.TrendPoint - err := s.db.WithContext(ctx). - Table("issues"). - Select("DATE(issues.created_at) AS date, "+ - "COUNT(CASE WHEN issues.created_at IS NOT NULL THEN 1 END) AS created, "+ - "COUNT(CASE WHEN issues.state = 'resolved' THEN 1 END) AS resolved"). - Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). - Joins("JOIN projects ON issues.project_id = projects.id"). - Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). - Group("DATE(issues.created_at)"). - Order("date ASC"). - Scan(&results).Error - + query := ` + WITH events AS ( + SELECT DATE(issues.created_at) AS date, 1 AS created, 0 AS resolved + FROM issues + JOIN workspaces ON issues.workspace_id = workspaces.id + JOIN projects ON issues.project_id = projects.id + WHERE workspaces.slug = ? + AND issues.deleted_at IS NULL + AND workspaces.deleted_at IS NULL + AND projects.deleted_at IS NULL + + UNION ALL + + SELECT DATE(issue_activities.created_at) AS date, 0 AS created, 1 AS resolved + FROM issue_activities + JOIN issues ON issue_activities.issue_id = issues.id + JOIN workspaces ON issues.workspace_id = workspaces.id + JOIN projects ON issues.project_id = projects.id + JOIN states ON issue_activities.new_state_id = states.id + WHERE workspaces.slug = ? + AND issues.deleted_at IS NULL + AND workspaces.deleted_at IS NULL + AND projects.deleted_at IS NULL + AND states.group IN ('completed', 'cancelled') + ) + SELECT date, SUM(created) AS created, SUM(resolved) AS resolved + FROM events + WHERE date IS NOT NULL + GROUP BY date + ORDER BY date ASC + ` + + err := s.db.WithContext(ctx).Raw(query, slug, slug).Scan(&results).Error return results, err } func (s *AnalyticsStore) GetProjectTrendAnalytics(ctx context.Context, projectID uuid.UUID) ([]model.TrendPoint, error) { var results []model.TrendPoint - err := s.db.WithContext(ctx). - Table("issues"). - Select("DATE(created_at) AS date, "+ - "COUNT(CASE WHEN created_at IS NOT NULL THEN 1 END) AS created, "+ - "COUNT(CASE WHEN state = 'resolved' THEN 1 END) AS resolved"). - Where("project_id = ? AND deleted_at IS NULL", projectID). - Group("DATE(created_at)"). - Order("date ASC"). - Scan(&results).Error - + query := ` + WITH events AS ( + SELECT DATE(issues.created_at) AS date, 1 AS created, 0 AS resolved + FROM issues + WHERE project_id = ? AND deleted_at IS NULL + + UNION ALL + + SELECT DATE(issue_activities.created_at) AS date, 0 AS created, 1 AS resolved + FROM issue_activities + JOIN issues ON issue_activities.issue_id = issues.id + JOIN states ON issue_activities.new_state_id = states.id + WHERE issues.project_id = ? + AND issues.deleted_at IS NULL + AND states.group IN ('completed', 'cancelled') + ) + SELECT date, SUM(created) AS created, SUM(resolved) AS resolved + FROM events + WHERE date IS NOT NULL + GROUP BY date + ORDER BY date ASC + ` + + err := s.db.WithContext(ctx).Raw(query, projectID, projectID).Scan(&results).Error return results, err } From db99f5ff121c7e0143318544181ddafee5ce2bd7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 16:41:09 -0400 Subject: [PATCH 28/28] now i think this commit corrects all issues from my code --- apps/api/internal/handler/analytics.go | 9 ++++-- apps/api/internal/model/analytics.go | 19 +++++++----- apps/api/internal/store/analytics.go | 43 ++++++++++++++++++-------- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/apps/api/internal/handler/analytics.go b/apps/api/internal/handler/analytics.go index 9ba3d9d..5a3ca9e 100644 --- a/apps/api/internal/handler/analytics.go +++ b/apps/api/internal/handler/analytics.go @@ -129,7 +129,7 @@ func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { 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"}); err != nil { + if err := writer.Write([]string{"Issue ID", "Title", "State", "Priority", "Assignee", "Labels"}); err != nil { return err } headerWritten = true @@ -140,6 +140,8 @@ func (h *AnalyticsHandler) ExportWorkspaceCSV(c *gin.Context) { sanitizeCSVField(issue.Name), sanitizeCSVField(issue.State), sanitizeCSVField(issue.Priority), + sanitizeCSVField(issue.Assignee), + sanitizeCSVField(issue.Labels), }); err != nil { return err } @@ -185,7 +187,7 @@ func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { 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"}); err != nil { + if err := writer.Write([]string{"Project Issue ID", "Title", "State", "Priority", "Assignee", "Labels"}); err != nil { return err } headerWritten = true @@ -195,6 +197,9 @@ func (h *AnalyticsHandler) ExportProjectCSV(c *gin.Context) { issue.ID, sanitizeCSVField(issue.Name), sanitizeCSVField(issue.State), + sanitizeCSVField(issue.Priority), + sanitizeCSVField(issue.Assignee), + sanitizeCSVField(issue.Labels), }); err != nil { return err } diff --git a/apps/api/internal/model/analytics.go b/apps/api/internal/model/analytics.go index 22548fa..91014b1 100644 --- a/apps/api/internal/model/analytics.go +++ b/apps/api/internal/model/analytics.go @@ -21,16 +21,21 @@ type LabelCount struct { } 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"` + 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 `gorm:"column:id" json:"id"` - Name string `gorm:"column:name" json:"name"` - State string `gorm:"column:state" json:"state"` + 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 { diff --git a/apps/api/internal/store/analytics.go b/apps/api/internal/store/analytics.go index 57f4dd2..6bc65ee 100644 --- a/apps/api/internal/store/analytics.go +++ b/apps/api/internal/store/analytics.go @@ -20,11 +20,12 @@ func (s *AnalyticsStore) GetWorkspaceStateAnalytics(ctx context.Context, slug st var results []model.StateCount err := s.db.WithContext(ctx). Table("issues"). - Select("issues.state, COUNT(*) as count"). + Select("states.name AS state, COUNT(*) as count"). Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). Joins("JOIN projects ON issues.project_id = projects.id"). + Joins("JOIN states ON issues.state_id = states.id"). Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). - Group("issues.state"). + Group("states.name"). Scan(&results).Error return results, err @@ -48,12 +49,13 @@ func (s *AnalyticsStore) GetWorkspaceAssigneeAnalytics(ctx context.Context, slug var results []model.AssigneeCount err := s.db.WithContext(ctx). Table("issues"). - Select("COALESCE(users.email, 'Unassigned') AS email, COUNT(*) as count"). + Select("COALESCE(users.email, 'Unassigned') AS email, COUNT(DISTINCT issues.id) as count"). Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). Joins("JOIN projects ON issues.project_id = projects.id"). - Joins("LEFT JOIN users ON issues.assignee_id = users.id"). + Joins("LEFT JOIN issue_assignees ON issues.id = issue_assignees.issue_id"). + Joins("LEFT JOIN users ON issue_assignees.assignee_id = users.id"). Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). - Group("COALESCE(users.email, 'Unassigned')"). + Group("users.email"). Scan(&results).Error return results, err @@ -103,10 +105,11 @@ func (s *AnalyticsStore) GetProjectAssigneeAnalytics(ctx context.Context, projec var results []model.AssigneeCount err := s.db.WithContext(ctx). Table("issues"). - Select("COALESCE(users.email, 'Unassigned') AS email, COUNT(*) as count"). - Joins("LEFT JOIN users ON issues.assignee_id = users.id"). + Select("COALESCE(users.email, 'Unassigned') AS email, COUNT(DISTINCT issues.id) as count"). + Joins("LEFT JOIN issue_assignees ON issues.id = issue_assignees.issue_id"). + Joins("LEFT JOIN users ON issue_assignees.assignee_id = users.id"). Where("issues.project_id = ? AND issues.deleted_at IS NULL", projectID). - Group("COALESCE(users.email, 'Unassigned')"). + Group("users.email"). Scan(&results).Error return results, err @@ -129,10 +132,16 @@ func (s *AnalyticsStore) GetProjectLabelAnalytics(ctx context.Context, projectID func (s *AnalyticsStore) StreamWorkspaceIssuesForExport(ctx context.Context, slug string, fn func(model.WorkspaceIssueExport) error) error { rows, err := s.db.WithContext(ctx). Table("issues"). - Select("issues.id AS id, issues.name AS name, issues.state AS state, issues.priority AS priority"). + Select("issues.id AS id, issues.name AS name, states.name AS state, issues.priority AS priority, COALESCE(STRING_AGG(DISTINCT users.email, ', '), '') AS assignee, COALESCE(STRING_AGG(DISTINCT labels.name, ', '), '') AS labels"). Joins("JOIN workspaces ON issues.workspace_id = workspaces.id"). Joins("JOIN projects ON issues.project_id = projects.id"). + Joins("JOIN states ON issues.state_id = states.id"). + Joins("LEFT JOIN issue_assignees ON issues.id = issue_assignees.issue_id"). + Joins("LEFT JOIN users ON issue_assignees.assignee_id = users.id"). + Joins("LEFT JOIN issue_labels ON issues.id = issue_labels.issue_id"). + Joins("LEFT JOIN labels ON issue_labels.label_id = labels.id"). Where("workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL", slug). + Group("issues.id, states.name"). Rows() if err != nil { return err @@ -154,8 +163,14 @@ func (s *AnalyticsStore) StreamWorkspaceIssuesForExport(ctx context.Context, slu func (s *AnalyticsStore) StreamProjectIssuesForExport(ctx context.Context, projectID uuid.UUID, fn func(model.ProjectIssueExport) error) error { rows, err := s.db.WithContext(ctx). Table("issues"). - Select("issues.id AS id, issues.name AS name, issues.state AS state"). - Where("project_id = ? AND deleted_at IS NULL", projectID). + Select("issues.id AS id, issues.name AS name, states.name AS state, issues.priority AS priority, COALESCE(STRING_AGG(DISTINCT users.email, ', '), '') AS assignee, COALESCE(STRING_AGG(DISTINCT labels.name, ', '), '') AS labels"). + Joins("JOIN states ON issues.state_id = states.id"). + Joins("LEFT JOIN issue_assignees ON issues.id = issue_assignees.issue_id"). + Joins("LEFT JOIN users ON issue_assignees.assignee_id = users.id"). + Joins("LEFT JOIN issue_labels ON issues.id = issue_labels.issue_id"). + Joins("LEFT JOIN labels ON issue_labels.label_id = labels.id"). + Where("issues.project_id = ? AND issues.deleted_at IS NULL", projectID). + Group("issues.id, states.name"). Rows() if err != nil { return err @@ -194,11 +209,12 @@ func (s *AnalyticsStore) GetWorkspaceTrendAnalytics(ctx context.Context, slug st JOIN issues ON issue_activities.issue_id = issues.id JOIN workspaces ON issues.workspace_id = workspaces.id JOIN projects ON issues.project_id = projects.id - JOIN states ON issue_activities.new_state_id = states.id + JOIN states ON states.id::text = issue_activities.new_value WHERE workspaces.slug = ? AND issues.deleted_at IS NULL AND workspaces.deleted_at IS NULL AND projects.deleted_at IS NULL + AND issue_activities.field = 'state_id' AND states.group IN ('completed', 'cancelled') ) SELECT date, SUM(created) AS created, SUM(resolved) AS resolved @@ -225,9 +241,10 @@ func (s *AnalyticsStore) GetProjectTrendAnalytics(ctx context.Context, projectID SELECT DATE(issue_activities.created_at) AS date, 0 AS created, 1 AS resolved FROM issue_activities JOIN issues ON issue_activities.issue_id = issues.id - JOIN states ON issue_activities.new_state_id = states.id + JOIN states ON states.id::text = issue_activities.new_value WHERE issues.project_id = ? AND issues.deleted_at IS NULL + AND issue_activities.field = 'state_id' AND states.group IN ('completed', 'cancelled') ) SELECT date, SUM(created) AS created, SUM(resolved) AS resolved
ProjectActions{t('common.project', 'Project')}{t('common.actions', 'Actions')}
- + + + {project.name}
{t('common.project', 'Project')}{t('common.actions', 'Actions')} + {t('common.project', 'Project')} + + {t('common.actions', 'Actions')} +