diff --git a/apps/api/go.mod b/apps/api/go.mod index 47d7bc43..187059d3 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -14,8 +14,9 @@ require ( github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 - golang.org/x/crypto v0.52.0 - golang.org/x/net v0.54.0 + github.com/xuri/excelize/v2 v2.11.0 + golang.org/x/crypto v0.53.0 + golang.org/x/net v0.56.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) @@ -91,14 +92,19 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/richardlehane/mscfb v1.0.7 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect github.com/rs/xid v1.6.0 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/tinylib/msgp v1.6.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect @@ -107,9 +113,9 @@ require ( go.opentelemetry.io/otel/trace v1.41.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.22.5 // indirect diff --git a/apps/api/go.sum b/apps/api/go.sum index eb5aad67..ce07381a 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -192,6 +192,10 @@ github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= +github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= @@ -216,6 +220,8 @@ github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44Xt github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= @@ -226,6 +232,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= +github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -250,22 +262,24 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= diff --git a/apps/api/internal/handler/export.go b/apps/api/internal/handler/export.go new file mode 100644 index 00000000..5ef48e2b --- /dev/null +++ b/apps/api/internal/handler/export.go @@ -0,0 +1,86 @@ +package handler + +import ( + "net/http" + + "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/service" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const xlsxContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + +// ExportHandler serves server-side issue exports. +type ExportHandler struct { + Export *service.ExportService +} + +// CreateExport generates an .xlsx of the selected projects' issues and streams +// it back, recording the request in the export history. +// POST /api/workspaces/:slug/exports/ +func (h *ExportHandler) CreateExport(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + slug := c.Param("slug") + var body struct { + ProjectIDs []string `json:"project_ids"` + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + projectIDs := make([]uuid.UUID, 0, len(body.ProjectIDs)) + for _, s := range body.ProjectIDs { + id, err := uuid.Parse(s) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID: " + s}) + return + } + projectIDs = append(projectIDs, id) + } + filename, data, err := h.Export.ExportIssues(c.Request.Context(), slug, user.ID, projectIDs, body.Name) + if err != nil { + switch err { + case service.ErrWorkspaceNotFound, service.ErrProjectNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + case service.ErrWorkspaceForbidden: + c.JSON(http.StatusForbidden, gin.H{"error": "Not a member of this workspace"}) + case service.ErrNoProjectsSelected: + c.JSON(http.StatusBadRequest, gin.H{"error": "Select at least one project to export"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate export"}) + } + return + } + c.Header("Content-Disposition", `attachment; filename="`+filename+`"`) + c.Data(http.StatusOK, xlsxContentType, data) +} + +// ListExports returns the workspace's export history. +// GET /api/workspaces/:slug/exports/ +func (h *ExportHandler) ListExports(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + slug := c.Param("slug") + list, err := h.Export.ListHistory(c.Request.Context(), slug, user.ID) + if err != nil { + switch err { + case service.ErrWorkspaceNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"}) + case service.ErrWorkspaceForbidden: + c.JSON(http.StatusForbidden, gin.H{"error": "Not a member of this workspace"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list exports"}) + } + return + } + c.JSON(http.StatusOK, gin.H{"exports": list}) +} diff --git a/apps/api/internal/handler/export_test.go b/apps/api/internal/handler/export_test.go new file mode 100644 index 00000000..9f0f24ab --- /dev/null +++ b/apps/api/internal/handler/export_test.go @@ -0,0 +1,70 @@ +package handler_test + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" + "github.com/xuri/excelize/v2" +) + +// A workspace member can export a project's issues as a real .xlsx workbook that +// parses back and contains the issues, the request is recorded in the export +// history, and non-members / empty selections are refused. Covers #198. +func TestExport_IssuesXLSXAndHistory(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + url := "/api/workspaces/" + w.Workspace.Slug + "/exports/" + + i1 := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + + rr := ts.POST(url, map[string]any{"project_ids": []string{w.Project.ID.String()}}, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + require.Contains(t, rr.Header().Get("Content-Type"), "spreadsheetml.sheet") + + body := rr.Body.Bytes() + require.True(t, len(body) > 4 && body[0] == 'P' && body[1] == 'K', "response should be an xlsx (zip) file") + + // It opens as a real workbook and contains the exported issue. + f, err := excelize.OpenReader(bytes.NewReader(body)) + require.NoError(t, err) + rows, err := f.GetRows("Issues") + require.NoError(t, err) + require.GreaterOrEqual(t, len(rows), 3, "header row + two issues") + require.Equal(t, "Title", rows[0][2]) + found := false + for _, r := range rows[1:] { + if len(r) > 2 && r[2] == i1.Name { + found = true + } + } + require.True(t, found, "exported sheet should contain the issue title") + + // The request is recorded in the export history. + lr := ts.GET(url, w.Session) + require.Equal(t, http.StatusOK, lr.Code) + var hist struct { + Exports []struct { + Provider string `json:"provider"` + Status string `json:"status"` + } `json:"exports"` + } + require.NoError(t, json.Unmarshal(lr.Body.Bytes(), &hist)) + require.Len(t, hist.Exports, 1) + require.Equal(t, "xlsx", hist.Exports[0].Provider) + require.Equal(t, "completed", hist.Exports[0].Status) + + // A non-member is refused. + outsider := testutil.CreateUser(t, ts.DB) + outsiderSession := testutil.LoginAs(t, ts.DB, outsider) + require.Equal(t, http.StatusForbidden, + ts.POST(url, map[string]any{"project_ids": []string{w.Project.ID.String()}}, outsiderSession).Code) + + // An empty selection is a 400. + require.Equal(t, http.StatusBadRequest, + ts.POST(url, map[string]any{"project_ids": []string{}}, w.Session).Code) +} diff --git a/apps/api/internal/model/exporter.go b/apps/api/internal/model/exporter.go new file mode 100644 index 00000000..64012643 --- /dev/null +++ b/apps/api/internal/model/exporter.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Exporter matches the exporters table: one row per issue-export request, kept +// as the export history. project_ids are recorded inside Filters so we avoid the +// UUID[] column's array-driver quirks. +type Exporter struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(255)" json:"name,omitempty"` + Type string `gorm:"type:varchar(50);default:issue_exports" json:"type"` + Provider string `gorm:"type:varchar(50);not null" json:"provider"` + Status string `gorm:"type:varchar(50);not null;default:queued" json:"status"` + Reason string `gorm:"type:text;default:''" json:"reason,omitempty"` + Filters JSONMap `gorm:"type:jsonb;serializer:json" json:"filters,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + InitiatedByID uuid.UUID `gorm:"type:uuid;not null" json:"initiated_by_id"` +} + +func (Exporter) TableName() string { return "exporters" } + +func (e *Exporter) BeforeCreate(tx *gorm.DB) error { + if e.ID == uuid.Nil { + e.ID = uuid.New() + } + return nil +} diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 7dac002f..38c2e7cb 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -146,6 +146,8 @@ func New(cfg Config) *gin.Engine { attachmentSvc := service.NewAttachmentService(issueStore, projectStore, workspaceStore, cfg.Minio) attachmentSvc.SetActivityStore(issueActivityStore) cycleSvc := service.NewCycleService(cycleStore, projectStore, workspaceStore) + exporterStore := store.NewExporterStore(cfg.DB) + exportSvc := service.NewExportService(exporterStore, issueStore, stateStore, projectStore, workspaceStore) cycleSvc.SetIssueStore(issueStore) moduleSvc := service.NewModuleService(moduleStore, projectStore, workspaceStore) moduleSvc.SetIssueStore(issueStore) @@ -242,6 +244,7 @@ func New(cfg Config) *gin.Engine { attachmentHandler := &handler.AttachmentHandler{Attachment: attachmentSvc} epicHandler := &handler.EpicHandler{Issue: issueSvc} cycleHandler := &handler.CycleHandler{Cycle: cycleSvc} + exportHandler := &handler.ExportHandler{Export: exportSvc} moduleHandler := &handler.ModuleHandler{Module: moduleSvc} issueViewHandler := &handler.IssueViewHandler{IssueView: issueViewSvc} pageHandler := &handler.PageHandler{Page: pageSvc} @@ -286,6 +289,8 @@ func New(cfg Config) *gin.Engine { api.GET("/workspaces/:slug/", workspaceHandler.GetBySlug) api.PATCH("/workspaces/:slug/", workspaceHandler.Update) api.DELETE("/workspaces/:slug/", workspaceHandler.Delete) + api.POST("/workspaces/:slug/exports/", exportHandler.CreateExport) + api.GET("/workspaces/:slug/exports/", exportHandler.ListExports) api.GET("/workspaces/:slug/tokens/", workspaceHandler.ListTokens) api.POST("/workspaces/:slug/tokens/", workspaceHandler.CreateToken) api.DELETE("/workspaces/:slug/tokens/:id/", workspaceHandler.RevokeToken) diff --git a/apps/api/internal/service/export.go b/apps/api/internal/service/export.go new file mode 100644 index 00000000..d73af7b8 --- /dev/null +++ b/apps/api/internal/service/export.go @@ -0,0 +1,184 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" + "github.com/xuri/excelize/v2" + "gorm.io/gorm" +) + +// ErrNoProjectsSelected is returned when an export names no projects. +var ErrNoProjectsSelected = errors.New("no projects selected for export") + +// ExportService generates issue exports and records them as history. +type ExportService struct { + exporters *store.ExporterStore + issues *store.IssueStore + states *store.StateStore + ps *store.ProjectStore + ws *store.WorkspaceStore +} + +func NewExportService(exporters *store.ExporterStore, issues *store.IssueStore, states *store.StateStore, ps *store.ProjectStore, ws *store.WorkspaceStore) *ExportService { + return &ExportService{exporters: exporters, issues: issues, states: states, ps: ps, ws: ws} +} + +// ExportIssues builds an .xlsx workbook of the issues in the given projects and +// records the request in the export history. Membership and project scope are +// enforced. Returns the suggested filename and the file bytes. +func (s *ExportService) ExportIssues(ctx context.Context, slug string, userID uuid.UUID, projectIDs []uuid.UUID, name string) (string, []byte, error) { + wrk, err := s.ws.GetBySlug(ctx, slug) + 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 { + return "", nil, err + } + if !ok { + return "", nil, ErrWorkspaceForbidden + } + if len(projectIDs) == 0 { + return "", nil, ErrNoProjectsSelected + } + for _, pid := range projectIDs { + in, err := s.ps.IsInWorkspace(ctx, pid, wrk.ID) + if err != nil { + return "", nil, err + } + if !in { + return "", nil, ErrProjectNotFound + } + } + + data, err := s.buildWorkbook(ctx, projectIDs) + if err != nil { + return "", nil, err + } + + idStrings := make([]string, 0, len(projectIDs)) + for _, pid := range projectIDs { + idStrings = append(idStrings, pid.String()) + } + rec := &model.Exporter{ + Name: name, + Type: "issue_exports", + Provider: "xlsx", + Status: "completed", + WorkspaceID: wrk.ID, + InitiatedByID: userID, + Filters: model.JSONMap{"project_ids": idStrings}, + } + if err := s.exporters.Create(ctx, rec); err != nil { + return "", nil, err + } + + return "devlane-issues-export.xlsx", data, nil +} + +// ListHistory returns the workspace's export history (members only). +func (s *ExportService) ListHistory(ctx context.Context, slug string, userID uuid.UUID) ([]model.Exporter, error) { + wrk, err := s.ws.GetBySlug(ctx, slug) + 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 { + return nil, err + } + if !ok { + return nil, ErrWorkspaceForbidden + } + return s.exporters.ListByWorkspaceID(ctx, wrk.ID) +} + +const exportPageSize = 500 + +func (s *ExportService) buildWorkbook(ctx context.Context, projectIDs []uuid.UUID) ([]byte, error) { + f := excelize.NewFile() + defer func() { _ = f.Close() }() + const sheet = "Issues" + f.SetSheetName(f.GetSheetName(0), sheet) + + headers := []string{"Project", "ID", "Title", "State", "Priority", "Start Date", "Due Date", "Created At"} + for i, h := range headers { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + _ = f.SetCellValue(sheet, cell, h) + } + + row := 2 + for _, pid := range projectIDs { + proj, err := s.ps.GetByID(ctx, pid) + if err != nil { + return nil, err + } + states, err := s.states.ListByProjectID(ctx, pid) + if err != nil { + return nil, err + } + stateName := make(map[uuid.UUID]string, len(states)) + for _, st := range states { + stateName[st.ID] = st.Name + } + + offset := 0 + for { + batch, err := s.issues.ListByProjectID(ctx, pid, exportPageSize, offset) + if err != nil { + return nil, err + } + for i := range batch { + iss := &batch[i] + displayID := fmt.Sprintf("%s-%d", proj.Identifier, iss.SequenceID) + st := "" + if iss.StateID != nil { + st = stateName[*iss.StateID] + } + values := []interface{}{ + proj.Name, + displayID, + iss.Name, + st, + iss.Priority, + formatExportDate(iss.StartDate), + formatExportDate(iss.TargetDate), + iss.CreatedAt.UTC().Format("2006-01-02"), + } + for c, v := range values { + cell, _ := excelize.CoordinatesToCellName(c+1, row) + _ = f.SetCellValue(sheet, cell, v) + } + row++ + } + if len(batch) < exportPageSize { + break + } + offset += exportPageSize + } + } + + buf, err := f.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func formatExportDate(t *time.Time) string { + if t == nil { + return "" + } + return t.UTC().Format("2006-01-02") +} diff --git a/apps/api/internal/store/exporter.go b/apps/api/internal/store/exporter.go new file mode 100644 index 00000000..a67b8e6e --- /dev/null +++ b/apps/api/internal/store/exporter.go @@ -0,0 +1,32 @@ +package store + +import ( + "context" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ExporterStore persists export-history records. +type ExporterStore struct{ db *gorm.DB } + +func NewExporterStore(db *gorm.DB) *ExporterStore { return &ExporterStore{db: db} } + +// Create records an export request. +func (s *ExporterStore) Create(ctx context.Context, e *model.Exporter) error { + return s.db.WithContext(ctx).Create(e).Error +} + +// ListByWorkspaceID returns a workspace's export history, newest first. +func (s *ExporterStore) ListByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]model.Exporter, error) { + var list []model.Exporter + err := s.db.WithContext(ctx). + Where("workspace_id = ?", workspaceID). + Order("created_at DESC"). + Find(&list).Error + if err != nil { + return nil, err + } + return list, nil +} diff --git a/apps/web/src/components/settings/modals/ExportModal.tsx b/apps/web/src/components/settings/modals/ExportModal.tsx index 7713e10e..d2900cde 100644 --- a/apps/web/src/components/settings/modals/ExportModal.tsx +++ b/apps/web/src/components/settings/modals/ExportModal.tsx @@ -1,8 +1,19 @@ +import { useState } from 'react'; import { Button, Modal } from '../../ui'; import { IconChevronDown } from '../icons'; import { issueService } from '../../../services/issueService'; +import { exportService } from '../../../services/exportService'; import type { ProjectApiResponse, WorkspaceApiResponse } from '../../../api/types'; +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + interface ExportModalProps { open: boolean; onClose: () => void; @@ -31,6 +42,7 @@ export function ExportModal({ setExporting, setExportProjectOpen, }: ExportModalProps) { + const [error, setError] = useState(null); return ( { if (!workspaceSlug) return; + setError(null); setExporting(true); try { const projectIds = exportProjectValue === 'all' ? projects.map((p) => p.id) : [exportProjectValue]; + const base = `export-${workspace?.slug ?? workspaceSlug}-${new Date().toISOString().slice(0, 10)}`; + + // Excel is generated server-side (real .xlsx); CSV/JSON stay client-side. + if (exportFormat === 'xlsx') { + const blob = await exportService.createXlsx(workspaceSlug, projectIds); + downloadBlob(blob, `${base}.xlsx`); + setExportProjectOpen(false); + return; + } + const allIssues: Array< Record & { project_id?: string; @@ -77,11 +100,9 @@ export function ExportModal({ offset += issues.length; } } - const fmt = exportFormat === 'xlsx' ? 'csv' : exportFormat; let blob: Blob; let filename: string; - const base = `export-${workspace?.slug ?? workspaceSlug}-${new Date().toISOString().slice(0, 10)}`; - if (fmt === 'json') { + if (exportFormat === 'json') { const str = JSON.stringify(allIssues, null, 2); blob = new Blob([str], { type: 'application/json' }); filename = `${base}.json`; @@ -118,15 +139,10 @@ export function ExportModal({ blob = new Blob([csv], { type: 'text/csv' }); filename = `${base}.csv`; } - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - setTimeout(() => URL.revokeObjectURL(url), 0); + downloadBlob(blob, filename); setExportProjectOpen(false); } catch { - // could set export error state + setError('Export failed. Please try again.'); } finally { setExporting(false); } @@ -158,9 +174,10 @@ export function ExportModal({ {exportFormat === 'xlsx' && (

- Excel export will download as CSV for now. + Exports as a real Excel workbook (.xlsx) generated on the server.

)} + {error &&

{error}

}
); diff --git a/apps/web/src/services/exportService.ts b/apps/web/src/services/exportService.ts new file mode 100644 index 00000000..a43af2f5 --- /dev/null +++ b/apps/web/src/services/exportService.ts @@ -0,0 +1,32 @@ +import { apiClient } from '../api/client'; + +export interface ExportHistoryItem { + id: string; + name?: string; + type: string; + provider: string; + status: string; + created_at: string; +} + +export const exportService = { + /** + * Generate a server-side .xlsx of the given projects' issues and return it as + * a Blob to download. The request is recorded in the workspace export history. + */ + async createXlsx(workspaceSlug: string, projectIds: string[], name?: string): Promise { + const { data } = await apiClient.post( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/exports/`, + { project_ids: projectIds, name }, + { responseType: 'blob' }, + ); + return data; + }, + + async listHistory(workspaceSlug: string): Promise<{ exports: ExportHistoryItem[] }> { + const { data } = await apiClient.get<{ exports: ExportHistoryItem[] }>( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/exports/`, + ); + return data; + }, +};