Skip to content

Commit fa53fda

Browse files
feat: export Org Projects as CSV (#918)
* feat: update proto * feat: export org projects * lint fix * lint fix * update proto
1 parent 86afa19 commit fa53fda

File tree

9 files changed

+1438
-921
lines changed

9 files changed

+1438
-921
lines changed

Diff for: Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
44
VERSION := $(shell git describe --tags ${TAG})
55
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto ui compose-up-dev
66
.DEFAULT_GOAL := build
7-
PROTON_COMMIT := "2558857f074805048966ede02501b5e9508f8341"
7+
PROTON_COMMIT := "740c3a1b4aeb64592c1b5b39f49b703489c6a8a6"
88

99
ui:
1010
@echo " > generating ui build"

Diff for: core/aggregates/orgprojects/service.go

+104
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
package orgprojects
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/csv"
7+
"errors"
8+
"fmt"
9+
"reflect"
10+
"strings"
511
"time"
612

713
"github.com/raystack/frontier/core/project"
814
"github.com/raystack/salt/rql"
915
)
1016

17+
var ErrNoContent = errors.New("no content")
18+
19+
const CSVContentType = "text/csv"
20+
1121
type Repository interface {
1222
Search(ctx context.Context, orgID string, query *rql.Query) (OrgProjects, error)
1323
}
@@ -54,6 +64,100 @@ type AggregatedProject struct {
5464
UserIDs []string
5565
}
5666

67+
// CSVExport represents the structure for CSV export of organization projects
68+
type CSVExport struct {
69+
ProjectID string `csv:"Project ID"`
70+
Name string `csv:"Name"`
71+
Title string `csv:"Title"`
72+
State string `csv:"State"`
73+
MemberCount string `csv:"Member Count"`
74+
UserIDs string `csv:"User IDs"`
75+
CreatedAt string `csv:"Created At"`
76+
OrganizationID string `csv:"Organization ID"`
77+
}
78+
79+
// NewCSVExport converts AggregatedProject to CSVExport
80+
func NewCSVExport(project AggregatedProject) CSVExport {
81+
return CSVExport{
82+
ProjectID: project.ID,
83+
Name: project.Name,
84+
Title: project.Title,
85+
State: string(project.State),
86+
MemberCount: fmt.Sprint(project.MemberCount),
87+
UserIDs: strings.Join(project.UserIDs, ","), // Comma-separated list of user IDs
88+
CreatedAt: project.CreatedAt.Format(time.RFC3339),
89+
OrganizationID: project.OrganizationID,
90+
}
91+
}
92+
93+
// GetHeaders returns the CSV headers based on struct tags
94+
func (c CSVExport) GetHeaders() []string {
95+
t := reflect.TypeOf(c)
96+
headers := make([]string, t.NumField())
97+
98+
for i := 0; i < t.NumField(); i++ {
99+
field := t.Field(i)
100+
if tag := field.Tag.Get("csv"); tag != "" {
101+
headers[i] = tag
102+
} else {
103+
headers[i] = field.Name
104+
}
105+
}
106+
107+
return headers
108+
}
109+
110+
// ToRow converts the struct to a string slice for CSV writing
111+
func (c CSVExport) ToRow() []string {
112+
v := reflect.ValueOf(c)
113+
row := make([]string, v.NumField())
114+
115+
for i := 0; i < v.NumField(); i++ {
116+
row[i] = fmt.Sprint(v.Field(i).Interface())
117+
}
118+
119+
return row
120+
}
121+
57122
func (s Service) Search(ctx context.Context, orgID string, query *rql.Query) (OrgProjects, error) {
58123
return s.repository.Search(ctx, orgID, query)
59124
}
125+
126+
// Export generates a CSV file containing organization projects data
127+
func (s Service) Export(ctx context.Context, orgID string) ([]byte, string, error) {
128+
orgProjectsData, err := s.repository.Search(ctx, orgID, &rql.Query{})
129+
if err != nil {
130+
return nil, "", fmt.Errorf("failed to search organization projects: %w", err)
131+
}
132+
133+
orgProjectsData.Projects = []AggregatedProject{}
134+
if len(orgProjectsData.Projects) == 0 {
135+
return nil, "", fmt.Errorf("%w: no projects found for organization %s", ErrNoContent, orgID)
136+
}
137+
138+
// Create a buffer to write CSV data
139+
var buf bytes.Buffer
140+
writer := csv.NewWriter(&buf)
141+
142+
// Write headers
143+
csvExport := NewCSVExport(orgProjectsData.Projects[0])
144+
headers := csvExport.GetHeaders()
145+
if err := writer.Write(headers); err != nil {
146+
return nil, "", fmt.Errorf("error writing CSV headers: %w", err)
147+
}
148+
149+
// Write data rows
150+
for _, project := range orgProjectsData.Projects {
151+
csvExport := NewCSVExport(project)
152+
if err := writer.Write(csvExport.ToRow()); err != nil {
153+
return nil, "", fmt.Errorf("error writing CSV row: %w", err)
154+
}
155+
}
156+
157+
writer.Flush()
158+
if err := writer.Error(); err != nil {
159+
return nil, "", fmt.Errorf("error flushing CSV writer: %w", err)
160+
}
161+
162+
return buf.Bytes(), CSVContentType, nil
163+
}

Diff for: internal/api/v1beta1/org_projects.go

+29
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import (
1010
"github.com/raystack/frontier/pkg/utils"
1111
frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1"
1212
"github.com/raystack/salt/rql"
13+
httpbody "google.golang.org/genproto/googleapis/api/httpbody"
1314
"google.golang.org/grpc/codes"
1415
"google.golang.org/grpc/status"
1516
"google.golang.org/protobuf/types/known/timestamppb"
1617
)
1718

1819
type OrgProjectsService interface {
1920
Search(ctx context.Context, id string, query *rql.Query) (orgprojects.OrgProjects, error)
21+
Export(ctx context.Context, orgID string) ([]byte, string, error)
2022
}
2123

2224
func (h Handler) SearchOrganizationProjects(ctx context.Context, request *frontierv1beta1.SearchOrganizationProjectsRequest) (*frontierv1beta1.SearchOrganizationProjectsResponse, error) {
@@ -65,6 +67,33 @@ func (h Handler) SearchOrganizationProjects(ctx context.Context, request *fronti
6567
}, nil
6668
}
6769

70+
func (h Handler) ExportOrganizationProjects(req *frontierv1beta1.ExportOrganizationProjectsRequest, stream frontierv1beta1.AdminService_ExportOrganizationProjectsServer) error {
71+
orgProjectsDataBytes, contentType, err := h.orgProjectsService.Export(stream.Context(), req.GetId())
72+
if err != nil {
73+
if errors.Is(err, orgprojects.ErrNoContent) {
74+
return status.Errorf(codes.InvalidArgument, fmt.Sprintf("no data to export: %v", err))
75+
}
76+
return err
77+
}
78+
79+
chunkSize := 1024 * 200 // 200KB chunks
80+
81+
for i := 0; i < len(orgProjectsDataBytes); i += chunkSize {
82+
end := min(i+chunkSize, len(orgProjectsDataBytes))
83+
84+
chunk := orgProjectsDataBytes[i:end]
85+
msg := &httpbody.HttpBody{
86+
ContentType: contentType,
87+
Data: chunk,
88+
}
89+
90+
if err := stream.Send(msg); err != nil {
91+
return fmt.Errorf("failed to send chunk: %v", err)
92+
}
93+
}
94+
return nil
95+
}
96+
6897
// Helper function to transform domain model to protobuf
6998
func transformAggregatedProjectToPB(p orgprojects.AggregatedProject) *frontierv1beta1.SearchOrganizationProjectsResponse_OrganizationProject {
7099
return &frontierv1beta1.SearchOrganizationProjectsResponse_OrganizationProject{

Diff for: pkg/server/interceptors/authorization.go

+3
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,9 @@ var authorizationValidationMap = map[string]func(ctx context.Context, handler *v
947947
"/raystack.frontier.v1beta1.AdminService/ExportOrganizationUsers": func(ctx context.Context, handler *v1beta1.Handler, req any) error {
948948
return handler.IsSuperUser(ctx)
949949
},
950+
"/raystack.frontier.v1beta1.AdminService/ExportOrganizationProjects": func(ctx context.Context, handler *v1beta1.Handler, req any) error {
951+
return handler.IsSuperUser(ctx)
952+
},
950953
"/raystack.frontier.v1beta1.AdminService/SetOrganizationKyc": func(ctx context.Context, handler *v1beta1.Handler, req any) error {
951954
return handler.IsSuperUser(ctx)
952955
},

Diff for: proto/apidocs.swagger.yaml

+46
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,52 @@ paths:
349349
format: int32
350350
tags:
351351
- Organization
352+
/v1beta1/admin/organizations/{id}/projects/export:
353+
get:
354+
summary: Export organization projects
355+
description: Export organization projects with user IDs
356+
operationId: AdminService_ExportOrganizationProjects
357+
responses:
358+
"200":
359+
description: A successful response.(streaming responses)
360+
schema:
361+
type: string
362+
format: binary
363+
properties: {}
364+
title: Free form byte stream
365+
"400":
366+
description: Bad Request - The request was malformed or contained invalid parameters.
367+
schema:
368+
$ref: '#/definitions/googlerpcStatus'
369+
"401":
370+
description: Unauthorized - Authentication is required
371+
schema:
372+
$ref: '#/definitions/googlerpcStatus'
373+
"403":
374+
description: Forbidden - User does not have permission to access the resource
375+
schema:
376+
$ref: '#/definitions/googlerpcStatus'
377+
"404":
378+
description: Not Found - The requested resource was not found
379+
schema:
380+
$ref: '#/definitions/googlerpcStatus'
381+
"500":
382+
description: Internal Server Error. Returned when theres is something wrong with Frontier server.
383+
schema:
384+
$ref: '#/definitions/googlerpcStatus'
385+
default:
386+
description: An unexpected error response.
387+
schema:
388+
$ref: '#/definitions/googlerpcStatus'
389+
parameters:
390+
- name: id
391+
in: path
392+
required: true
393+
type: string
394+
tags:
395+
- Organization
396+
produces:
397+
- text/csv
352398
/v1beta1/admin/organizations/{id}/projects/search:
353399
post:
354400
summary: Search organization projects

0 commit comments

Comments
 (0)