diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index df735338..00e5ed7c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5.0.2 with: - go-version: ^1.23 + go-version: ^1.24 id: go - name: Check out code diff --git a/Dockerfile b/Dockerfile index 6036d71c..16f080d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23 AS build-env +FROM golang:1.24 AS build-env WORKDIR /go/malak LABEL org.opencontainers.image.description="Open source Investors' relationship hub for Founders" diff --git a/cmd/http.go b/cmd/http.go index 620bf593..b49eafee 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -120,6 +120,7 @@ func addHTTPCommand(c *cobra.Command, cfg *config.Config) { shareRepo := postgres.NewShareRepository(db) preferenceRepo := postgres.NewPreferenceRepository(db) integrationRepo := postgres.NewIntegrationRepo(db) + dashRepo := postgres.NewDashboardRepo(db) googleAuthProvider := socialauth.NewGoogle(*cfg) @@ -274,6 +275,7 @@ func addHTTPCommand(c *cobra.Command, cfg *config.Config) { srv, cleanupSrv := server.New(logger, util.DeRef(cfg), db, tokenManager, googleAuthProvider, + dashRepo, userRepo, workspaceRepo, planRepo, contactRepo, updateRepo, contactlistRepo, deckRepo, shareRepo, preferenceRepo, integrationRepo, mid, gulterHandler, diff --git a/dashboard.go b/dashboard.go index 2646c27f..15134c43 100644 --- a/dashboard.go +++ b/dashboard.go @@ -1,8 +1,71 @@ package malak -import "github.com/google/uuid" +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +var ( + ErrDashboardNotFound = MalakError("dashboard not found") +) type Dashboard struct { - ID uuid.UUID `json:"id,omitempty"` - Reference Reference `json:"reference,omitempty"` + ID uuid.UUID `bun:"type:uuid,default:uuid_generate_v4(),pk" json:"id,omitempty"` + Reference Reference `json:"reference,omitempty"` + Description string `json:"description,omitempty"` + Title string `json:"title,omitempty"` + WorkspaceID uuid.UUID `json:"workspace_id,omitempty"` + + ChartCount int64 `json:"chart_count,omitempty"` + + CreatedAt time.Time `json:"created_at,omitempty" bun:",default:current_timestamp"` + UpdatedAt time.Time `json:"updated_at,omitempty" bun:",default:current_timestamp"` + + DeletedAt *time.Time `bun:",soft_delete,nullzero" json:"-,omitempty"` + + bun.BaseModel `json:"-"` +} + +type DashboardChart struct { + ID uuid.UUID `bun:"type:uuid,default:uuid_generate_v4(),pk" json:"id,omitempty"` + WorkspaceIntegrationID uuid.UUID `json:"workspace_integration_id,omitempty"` + Reference Reference `json:"reference,omitempty"` + WorkspaceID uuid.UUID `json:"workspace_id,omitempty"` + DashboardID uuid.UUID `json:"dashboard_id,omitempty"` + + ChartID uuid.UUID `json:"chart_id,omitempty"` + IntegrationChart *IntegrationChart `json:"chart,omitempty" bun:"rel:belongs-to,join:chart_id=id"` + + CreatedAt time.Time `json:"created_at,omitempty" bun:",default:current_timestamp"` + UpdatedAt time.Time `json:"updated_at,omitempty" bun:",default:current_timestamp"` + + DeletedAt *time.Time `bun:",soft_delete,nullzero" json:"-,omitempty"` + + bun.BaseModel `json:"-"` +} + +type ListDashboardOptions struct { + Paginator Paginator + WorkspaceID uuid.UUID +} + +type FetchDashboardOption struct { + WorkspaceID uuid.UUID + Reference Reference +} + +type FetchDashboardChartsOption struct { + WorkspaceID uuid.UUID + DashboardID uuid.UUID +} + +type DashboardRepository interface { + Create(context.Context, *Dashboard) error + Get(context.Context, FetchDashboardOption) (Dashboard, error) + AddChart(context.Context, *DashboardChart) error + List(context.Context, ListDashboardOptions) ([]Dashboard, int64, error) + GetCharts(context.Context, FetchDashboardChartsOption) ([]DashboardChart, error) } diff --git a/dev/otel-collector.yml b/dev/otel-collector.yml index 98847c72..0b05ce6e 100644 --- a/dev/otel-collector.yml +++ b/dev/otel-collector.yml @@ -1,27 +1,27 @@ receivers: -prometheus: - config: - scrape_configs: - - job_name: "bundler" - scrape_interval: 10s - static_configs: - - targets: ["host.docker.internal:4337"] - basic_auth: - username: malak - password: malak + prometheus: + config: + scrape_configs: + - job_name: "bundler" + scrape_interval: 10s + static_configs: + - targets: ["host.docker.internal:4337"] + basic_auth: + username: malak + password: malak processors: -batch: + batch: exporters: -otlp: - endpoint: "otel:4317" - tls: - insecure: true + otlp: + endpoint: "otel:4317" + tls: + insecure: true service: -pipelines: - metrics: - receivers: [prometheus] - processors: [batch] - exporters: [otlp] + pipelines: + metrics: + receivers: [prometheus] + processors: [batch] + exporters: [otlp] diff --git a/generate.go b/generate.go index 98328920..0f9a3736 100644 --- a/generate.go +++ b/generate.go @@ -28,3 +28,4 @@ package malak //go:generate mockgen -source=internal/pkg/billing/billing.go -destination=mocks/billing.go -package=malak_mocks //go:generate mockgen -source=internal/secret/secret.go -destination=mocks/secret.go -package=malak_mocks //go:generate mockgen -source=integration.go -destination=mocks/integration.go -package=malak_mocks +//go:generate mockgen -source=dashboard.go -destination=mocks/dashboard.go -package=malak_mocks diff --git a/go.mod b/go.mod index 28e9c559..9028aba8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ayinke-llc/malak -go 1.23.1 +go 1.24.0 require ( github.com/ThreeDotsLabs/watermill v1.3.7 diff --git a/go.sum b/go.sum index 049ba68b..85297a60 100644 --- a/go.sum +++ b/go.sum @@ -32,10 +32,6 @@ github.com/ThreeDotsLabs/watermill v1.3.7 h1:NV0PSTmuACVEOV4dMxRnmGXrmbz8U83LENO github.com/ThreeDotsLabs/watermill v1.3.7/go.mod h1:lBnrLbxOjeMRgcJbv+UiZr8Ylz8RkJ4m6i/VN/Nk+to= github.com/ThreeDotsLabs/watermill-redisstream v1.4.2 h1:FY6tsBcbhbJpKDOssU4bfybstqY0hQHwiZmVq9qyILQ= github.com/ThreeDotsLabs/watermill-redisstream v1.4.2/go.mod h1:69++855LyB+ckYDe60PiJLBcUrpckfDE2WwyzuVJRCk= -github.com/adelowo/gulter v0.0.0-20250118125244-ee5e3db48073 h1:wFakW12hAz7xQa6cLWruPTeIPPdn3fZ8OxcD7P+6r7k= -github.com/adelowo/gulter v0.0.0-20250118125244-ee5e3db48073/go.mod h1:emNdddTD8yk9NjprSHOXGqgMGk0Tj7j69gG8zyywv2M= -github.com/adelowo/gulter v0.0.0-20250212151604-30b84bd42d8d h1:q4UZxiHpxvoy/GTXb+Oa7Jodl7uCHoH7xOrpjDLa3Q4= -github.com/adelowo/gulter v0.0.0-20250212151604-30b84bd42d8d/go.mod h1:emNdddTD8yk9NjprSHOXGqgMGk0Tj7j69gG8zyywv2M= github.com/adelowo/gulter v0.0.0-20250212164855-be5012c5f635 h1:AMWTMYnpT/svarHQYlelgdHTYdN9pCkT6FwEflaNk60= github.com/adelowo/gulter v0.0.0-20250212164855-be5012c5f635/go.mod h1:emNdddTD8yk9NjprSHOXGqgMGk0Tj7j69gG8zyywv2M= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= @@ -659,8 +655,6 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/integration.go b/integration.go index b3692fd3..4224e519 100644 --- a/integration.go +++ b/integration.go @@ -11,6 +11,7 @@ import ( var ( ErrWorkspaceIntegrationNotFound = MalakError("integration not found") + ErrChartNotFound = MalakError("chart not found") ) // ENUM(oauth2,api_key) @@ -22,6 +23,12 @@ type IntegrationProvider string // ENUM(mercury_account,mercury_account_transaction,brex_account,brex_account_transaction) type IntegrationChartInternalNameType string +// ENUM(bar,pie) +type IntegrationChartType string + +// ENUM(daily,monthly) +type IntegrationChartFrequencyType uint8 + type IntegrationMetadata struct { Endpoint string `json:"endpoint,omitempty"` } @@ -107,6 +114,7 @@ type IntegrationChart struct { UserFacingName string `json:"user_facing_name,omitempty"` InternalName IntegrationChartInternalNameType `json:"internal_name,omitempty"` Metadata IntegrationChartMetadata `json:"metadata,omitempty"` + ChartType IntegrationChartType `json:"chart_type,omitempty"` CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"created_at,omitempty"` UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"updated_at,omitempty"` @@ -131,6 +139,7 @@ type IntegrationChartValues struct { InternalName IntegrationChartInternalNameType UserFacingName string ProviderID string + ChartType IntegrationChartType } type IntegrationFetchDataOptions struct { @@ -161,6 +170,11 @@ type FindWorkspaceIntegrationOptions struct { ID uuid.UUID } +type FetchChartOptions struct { + WorkspaceID uuid.UUID + Reference Reference +} + type IntegrationRepository interface { Create(context.Context, *Integration) error System(context.Context) ([]Integration, error) @@ -172,4 +186,6 @@ type IntegrationRepository interface { CreateCharts(context.Context, *WorkspaceIntegration, []IntegrationChartValues) error AddDataPoint(context.Context, *WorkspaceIntegration, []IntegrationDataValues) error + ListCharts(context.Context, uuid.UUID) ([]IntegrationChart, error) + GetChart(context.Context, FetchChartOptions) (IntegrationChart, error) } diff --git a/integration_enum.go b/integration_enum.go index b07e10f9..419ff811 100644 --- a/integration_enum.go +++ b/integration_enum.go @@ -11,6 +11,50 @@ import ( "fmt" ) +const ( + // IntegrationChartFrequencyTypeDaily is a IntegrationChartFrequencyType of type Daily. + IntegrationChartFrequencyTypeDaily IntegrationChartFrequencyType = iota + // IntegrationChartFrequencyTypeMonthly is a IntegrationChartFrequencyType of type Monthly. + IntegrationChartFrequencyTypeMonthly +) + +var ErrInvalidIntegrationChartFrequencyType = errors.New("not a valid IntegrationChartFrequencyType") + +const _IntegrationChartFrequencyTypeName = "dailymonthly" + +var _IntegrationChartFrequencyTypeMap = map[IntegrationChartFrequencyType]string{ + IntegrationChartFrequencyTypeDaily: _IntegrationChartFrequencyTypeName[0:5], + IntegrationChartFrequencyTypeMonthly: _IntegrationChartFrequencyTypeName[5:12], +} + +// String implements the Stringer interface. +func (x IntegrationChartFrequencyType) String() string { + if str, ok := _IntegrationChartFrequencyTypeMap[x]; ok { + return str + } + return fmt.Sprintf("IntegrationChartFrequencyType(%d)", x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x IntegrationChartFrequencyType) IsValid() bool { + _, ok := _IntegrationChartFrequencyTypeMap[x] + return ok +} + +var _IntegrationChartFrequencyTypeValue = map[string]IntegrationChartFrequencyType{ + _IntegrationChartFrequencyTypeName[0:5]: IntegrationChartFrequencyTypeDaily, + _IntegrationChartFrequencyTypeName[5:12]: IntegrationChartFrequencyTypeMonthly, +} + +// ParseIntegrationChartFrequencyType attempts to convert a string to a IntegrationChartFrequencyType. +func ParseIntegrationChartFrequencyType(name string) (IntegrationChartFrequencyType, error) { + if x, ok := _IntegrationChartFrequencyTypeValue[name]; ok { + return x, nil + } + return IntegrationChartFrequencyType(0), fmt.Errorf("%s is %w", name, ErrInvalidIntegrationChartFrequencyType) +} + const ( // IntegrationChartInternalNameTypeMercuryAccount is a IntegrationChartInternalNameType of type mercury_account. IntegrationChartInternalNameTypeMercuryAccount IntegrationChartInternalNameType = "mercury_account" @@ -51,6 +95,40 @@ func ParseIntegrationChartInternalNameType(name string) (IntegrationChartInterna return IntegrationChartInternalNameType(""), fmt.Errorf("%s is %w", name, ErrInvalidIntegrationChartInternalNameType) } +const ( + // IntegrationChartTypeBar is a IntegrationChartType of type bar. + IntegrationChartTypeBar IntegrationChartType = "bar" + // IntegrationChartTypePie is a IntegrationChartType of type pie. + IntegrationChartTypePie IntegrationChartType = "pie" +) + +var ErrInvalidIntegrationChartType = errors.New("not a valid IntegrationChartType") + +// String implements the Stringer interface. +func (x IntegrationChartType) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x IntegrationChartType) IsValid() bool { + _, err := ParseIntegrationChartType(string(x)) + return err == nil +} + +var _IntegrationChartTypeValue = map[string]IntegrationChartType{ + "bar": IntegrationChartTypeBar, + "pie": IntegrationChartTypePie, +} + +// ParseIntegrationChartType attempts to convert a string to a IntegrationChartType. +func ParseIntegrationChartType(name string) (IntegrationChartType, error) { + if x, ok := _IntegrationChartTypeValue[name]; ok { + return x, nil + } + return IntegrationChartType(""), fmt.Errorf("%s is %w", name, ErrInvalidIntegrationChartType) +} + const ( // IntegrationDataPointTypeCurrency is a IntegrationDataPointType of type currency. IntegrationDataPointTypeCurrency IntegrationDataPointType = "currency" diff --git a/internal/datastore/postgres/contact_list_test.go b/internal/datastore/postgres/contact_list_test.go index 0127d486..aa2cbe95 100644 --- a/internal/datastore/postgres/contact_list_test.go +++ b/internal/datastore/postgres/contact_list_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "testing" "github.com/ayinke-llc/malak" @@ -16,7 +15,7 @@ func TestContactList_Create(t *testing.T) { userRepo := NewUserRepository(client) - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) @@ -31,10 +30,10 @@ func TestContactList_Create(t *testing.T) { CreatedBy: user.ID, } - err = contactRepo.Create(context.Background(), list) + err = contactRepo.Create(t.Context(), list) require.NoError(t, err) - newList, err := contactRepo.Get(context.Background(), malak.FetchContactListOptions{ + newList, err := contactRepo.Get(t.Context(), malak.FetchContactListOptions{ Reference: list.Reference, WorkspaceID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) @@ -43,16 +42,16 @@ func TestContactList_Create(t *testing.T) { newList.Title = "Series A" - err = contactRepo.Update(context.Background(), newList) + err = contactRepo.Update(t.Context(), newList) require.NoError(t, err) - err = contactRepo.Delete(context.Background(), newList) + err = contactRepo.Delete(t.Context(), newList) require.NoError(t, err) // fetch again // - _, err = contactRepo.Get(context.Background(), malak.FetchContactListOptions{ + _, err = contactRepo.Get(t.Context(), malak.FetchContactListOptions{ Reference: list.Reference, WorkspaceID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) @@ -67,7 +66,7 @@ func TestContactList(t *testing.T) { userRepo := NewUserRepository(client) - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) @@ -84,12 +83,12 @@ func TestContactList(t *testing.T) { CreatedBy: user.ID, } - err = contactRepo.Create(context.Background(), list) + err = contactRepo.Create(t.Context(), list) require.NoError(t, err) } - lists, _, err := contactRepo.List(context.Background(), + lists, _, err := contactRepo.List(t.Context(), &malak.ContactListOptions{ WorkspaceID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) @@ -105,7 +104,7 @@ func TestContactList_Add(t *testing.T) { userRepo := NewUserRepository(client) - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) @@ -123,7 +122,7 @@ func TestContactList_Add(t *testing.T) { Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeContact), } - err = contactRepo.Create(context.Background(), contact) + err = contactRepo.Create(t.Context(), contact) require.NoError(t, err) list := &malak.ContactList{ @@ -133,17 +132,17 @@ func TestContactList_Add(t *testing.T) { CreatedBy: user.ID, } - err = contactListRepo.Create(context.Background(), list) + err = contactListRepo.Create(t.Context(), list) require.NoError(t, err) - newList, err := contactListRepo.Get(context.Background(), malak.FetchContactListOptions{ + newList, err := contactListRepo.Get(t.Context(), malak.FetchContactListOptions{ Reference: list.Reference, WorkspaceID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) require.Equal(t, list.Title, newList.Title) - _, mappings, err := contactListRepo.List(context.Background(), + _, mappings, err := contactListRepo.List(t.Context(), &malak.ContactListOptions{ WorkspaceID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) @@ -151,7 +150,7 @@ func TestContactList_Add(t *testing.T) { require.Len(t, mappings, 0) - err = contactListRepo.Add(context.Background(), &malak.ContactListMapping{ + err = contactListRepo.Add(t.Context(), &malak.ContactListMapping{ ListID: newList.ID, ContactID: contact.ID, Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeListEmail), @@ -159,7 +158,7 @@ func TestContactList_Add(t *testing.T) { }) require.NoError(t, err) - _, mappings, err = contactListRepo.List(context.Background(), + _, mappings, err = contactListRepo.List(t.Context(), &malak.ContactListOptions{ WorkspaceID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) diff --git a/internal/datastore/postgres/contact_test.go b/internal/datastore/postgres/contact_test.go index d784765f..77ed3fc4 100644 --- a/internal/datastore/postgres/contact_test.go +++ b/internal/datastore/postgres/contact_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "testing" "github.com/ayinke-llc/malak" @@ -29,11 +28,11 @@ func TestContact_Delete(t *testing.T) { } // Test successful creation - err := contactRepo.Create(context.Background(), contact) + err := contactRepo.Create(t.Context(), contact) require.NoError(t, err) // Verify contact was created - savedContact, err := contactRepo.Get(context.Background(), malak.FetchContactOptions{ + savedContact, err := contactRepo.Get(t.Context(), malak.FetchContactOptions{ Reference: contact.Reference, WorkspaceID: workspaceID, }) @@ -43,11 +42,11 @@ func TestContact_Delete(t *testing.T) { require.Equal(t, contact.Reference, savedContact.Reference) // Delete - err = contactRepo.Delete(context.Background(), contact) + err = contactRepo.Delete(t.Context(), contact) require.NoError(t, err) // contact was deleted, it should not be found - _, err = contactRepo.Get(context.Background(), malak.FetchContactOptions{ + _, err = contactRepo.Get(t.Context(), malak.FetchContactOptions{ Reference: contact.Reference, WorkspaceID: workspaceID, }) @@ -55,7 +54,7 @@ func TestContact_Delete(t *testing.T) { require.Equal(t, malak.ErrContactNotFound, err) // get contact from db - contact, err = contactRepo.Get(context.Background(), malak.FetchContactOptions{ + contact, err = contactRepo.Get(t.Context(), malak.FetchContactOptions{ Reference: "contact_kCoC286IR", // contacts.yml WorkspaceID: workspaceID, }) @@ -64,7 +63,7 @@ func TestContact_Delete(t *testing.T) { require.Equal(t, "contact_kCoC286IR", contact.Reference.String()) // Delete again - err = contactRepo.Delete(context.Background(), contact) + err = contactRepo.Delete(t.Context(), contact) require.NoError(t, err) } @@ -74,7 +73,7 @@ func TestContact_Get(t *testing.T) { contactRepo := NewContactRepository(client) - contact, err := contactRepo.Get(context.Background(), malak.FetchContactOptions{ + contact, err := contactRepo.Get(t.Context(), malak.FetchContactOptions{ Reference: "contact_kCoC286IR", // contacts.yml WorkspaceID: workspaceID, }) @@ -82,28 +81,28 @@ func TestContact_Get(t *testing.T) { require.NotNil(t, contact) require.Equal(t, "contact_kCoC286IR", contact.Reference.String()) - contactByID, err := contactRepo.Get(context.Background(), malak.FetchContactOptions{ + contactByID, err := contactRepo.Get(t.Context(), malak.FetchContactOptions{ ID: contact.ID, WorkspaceID: workspaceID, }) require.NoError(t, err) require.Equal(t, contact.ID, contactByID.ID) - contactByEmail, err := contactRepo.Get(context.Background(), malak.FetchContactOptions{ + contactByEmail, err := contactRepo.Get(t.Context(), malak.FetchContactOptions{ Email: contact.Email, WorkspaceID: workspaceID, }) require.NoError(t, err) require.Equal(t, contact.Email, contactByEmail.Email) - _, err = contactRepo.Get(context.Background(), malak.FetchContactOptions{ + _, err = contactRepo.Get(t.Context(), malak.FetchContactOptions{ Reference: "contact_kCo", WorkspaceID: workspaceID, }) require.Error(t, err) require.Equal(t, err, malak.ErrContactNotFound) - _, err = contactRepo.Get(context.Background(), malak.FetchContactOptions{ + _, err = contactRepo.Get(t.Context(), malak.FetchContactOptions{ Reference: malak.Reference(contact.Reference.String()), WorkspaceID: uuid.New(), }) @@ -126,11 +125,11 @@ func TestContact_Create(t *testing.T) { } // Test successful creation - err := contactRepo.Create(context.Background(), contact) + err := contactRepo.Create(t.Context(), contact) require.NoError(t, err) // Verify contact was created - savedContact, err := contactRepo.Get(context.Background(), malak.FetchContactOptions{ + savedContact, err := contactRepo.Get(t.Context(), malak.FetchContactOptions{ Reference: contact.Reference, WorkspaceID: workspaceID, }) @@ -140,7 +139,7 @@ func TestContact_Create(t *testing.T) { require.Equal(t, contact.Reference, savedContact.Reference) // Test duplicate creation - err = contactRepo.Create(context.Background(), contact) + err = contactRepo.Create(t.Context(), contact) require.Error(t, err) require.Equal(t, err, malak.ErrContactExists) } @@ -169,7 +168,7 @@ func TestContact_List(t *testing.T) { } for _, c := range contacts { - err := contactRepo.Create(context.Background(), c) + err := contactRepo.Create(t.Context(), c) require.NoError(t, err) } @@ -204,14 +203,14 @@ func TestContact_List(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, total, err := contactRepo.List(context.Background(), tt.opts) + result, total, err := contactRepo.List(t.Context(), tt.opts) require.NoError(t, err) require.Greater(t, total, int64(0)) require.Len(t, result, tt.expectedCount) }) } - result, total, err := contactRepo.List(context.Background(), malak.ListContactOptions{ + result, total, err := contactRepo.List(t.Context(), malak.ListContactOptions{ WorkspaceID: uuid.New(), Paginator: malak.Paginator{ Page: 1, @@ -229,7 +228,7 @@ func TestContact_Update(t *testing.T) { contactRepo := NewContactRepository(client) - contact, err := contactRepo.Get(context.Background(), malak.FetchContactOptions{ + contact, err := contactRepo.Get(t.Context(), malak.FetchContactOptions{ Reference: "contact_kCoC286IR", // contacts.yml WorkspaceID: workspaceID, }) @@ -239,7 +238,7 @@ func TestContact_Update(t *testing.T) { newEmail := faker.Email() - _, err = contactRepo.Get(context.Background(), malak.FetchContactOptions{ + _, err = contactRepo.Get(t.Context(), malak.FetchContactOptions{ ID: contact.ID, WorkspaceID: workspaceID, Email: malak.Email(newEmail), @@ -248,9 +247,9 @@ func TestContact_Update(t *testing.T) { require.Equal(t, malak.ErrContactNotFound, err) contact.Email = malak.Email(newEmail) - require.NoError(t, contactRepo.Update(context.Background(), contact)) + require.NoError(t, contactRepo.Update(t.Context(), contact)) - _, err = contactRepo.Get(context.Background(), malak.FetchContactOptions{ + _, err = contactRepo.Get(t.Context(), malak.FetchContactOptions{ ID: contact.ID, WorkspaceID: workspaceID, Email: malak.Email(newEmail), diff --git a/internal/datastore/postgres/dashboard.go b/internal/datastore/postgres/dashboard.go new file mode 100644 index 00000000..2490860c --- /dev/null +++ b/internal/datastore/postgres/dashboard.go @@ -0,0 +1,130 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/ayinke-llc/malak" + "github.com/uptrace/bun" +) + +type dashboardRepo struct { + inner *bun.DB +} + +func NewDashboardRepo(inner *bun.DB) malak.DashboardRepository { + return &dashboardRepo{ + inner: inner, + } +} + +func (d *dashboardRepo) Create(ctx context.Context, + dashboard *malak.Dashboard) error { + + ctx, cancelFn := withContext(ctx) + defer cancelFn() + + return d.inner.RunInTx(ctx, &sql.TxOptions{}, + func(ctx context.Context, tx bun.Tx) error { + + dashboard.ChartCount = 0 + + _, err := tx.NewInsert(). + Model(dashboard). + Exec(ctx) + return err + }) +} + +func (d *dashboardRepo) AddChart(ctx context.Context, + dashboardChart *malak.DashboardChart) error { + + return d.inner.RunInTx(ctx, &sql.TxOptions{}, + func(ctx context.Context, tx bun.Tx) error { + + _, err := tx.NewInsert(). + Model(dashboardChart). + Exec(ctx) + if err != nil { + return err + } + + _, err = tx.NewUpdate(). + Model(new(malak.Dashboard)). + Where("id = ?", dashboardChart.DashboardID). + Set("updated_at = ?", time.Now()). + Set("chart_count = chart_count + 1"). + Exec(ctx) + return err + }) +} + +func (d *dashboardRepo) List(ctx context.Context, + opts malak.ListDashboardOptions) ([]malak.Dashboard, int64, error) { + + ctx, cancelFn := withContext(ctx) + defer cancelFn() + + dashboards := make([]malak.Dashboard, 0, opts.Paginator.PerPage) + + q := d.inner.NewSelect(). + Order("created_at DESC"). + Where("workspace_id = ?", opts.WorkspaceID) + + // Get total count with same filters + total, err := q. + Model(&dashboards). + Count(ctx) + if err != nil { + return nil, 0, err + } + + // Get paginated results + err = q.Model(&dashboards). + Limit(int(opts.Paginator.PerPage)). + Offset(int(opts.Paginator.Offset())). + Scan(ctx) + + return dashboards, int64(total), err +} + +func (d *dashboardRepo) Get(ctx context.Context, + opts malak.FetchDashboardOption) (malak.Dashboard, error) { + + ctx, cancelFn := withContext(ctx) + defer cancelFn() + + dashboard := malak.Dashboard{} + + err := d.inner.NewSelect(). + Model(&dashboard). + Where("workspace_id = ?", opts.WorkspaceID). + Where("reference = ?", opts.Reference). + Scan(ctx) + if errors.Is(err, sql.ErrNoRows) { + err = malak.ErrDashboardNotFound + } + + return dashboard, err +} + +func (d *dashboardRepo) GetCharts(ctx context.Context, + opts malak.FetchDashboardChartsOption) ([]malak.DashboardChart, error) { + + ctx, cancelFn := withContext(ctx) + defer cancelFn() + + charts := make([]malak.DashboardChart, 0) + + err := d.inner.NewSelect(). + Model(&charts). + Relation("IntegrationChart"). + Order("dashboard_chart.created_at DESC"). + Where("dashboard_chart.workspace_id = ?", opts.WorkspaceID). + Where("dashboard_id = ?", opts.DashboardID). + Scan(ctx) + + return charts, err +} diff --git a/internal/datastore/postgres/dashboard_test.go b/internal/datastore/postgres/dashboard_test.go new file mode 100644 index 00000000..3c54bbe8 --- /dev/null +++ b/internal/datastore/postgres/dashboard_test.go @@ -0,0 +1,575 @@ +package postgres + +import ( + "testing" + + "github.com/ayinke-llc/malak" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestDashboard_Create(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + dashboardRepo := NewDashboardRepo(client) + workspaceRepo := NewWorkspaceRepository(client) + + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("c12da796-9362-4c70-b2cb-fc8a1eba2526"), + }) + require.NoError(t, err) + + dashboard := &malak.Dashboard{ + WorkspaceID: workspace.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "Test Dashboard", + Description: "Test Dashboard Description", + } + + err = dashboardRepo.Create(t.Context(), dashboard) + require.NoError(t, err) + require.NotEmpty(t, dashboard.ID) + require.Equal(t, int64(0), dashboard.ChartCount) +} + +func TestDashboard_AddChart(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + dashboardRepo := NewDashboardRepo(client) + workspaceRepo := NewWorkspaceRepository(client) + integrationRepo := NewIntegrationRepo(client) + + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), + }) + require.NoError(t, err) + + integration := &malak.Integration{ + IntegrationName: "Mercury", + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), + Description: "Mercury Banking Integration", + IsEnabled: true, + IntegrationType: malak.IntegrationTypeOauth2, + LogoURL: "https://mercury.com/logo.png", + } + err = integrationRepo.Create(t.Context(), integration) + require.NoError(t, err) + + integrations, err := integrationRepo.List(t.Context(), workspace) + require.NoError(t, err) + require.Len(t, integrations, 1) + workspaceIntegration := integrations[0] + + chartValues := []malak.IntegrationChartValues{ + { + UserFacingName: "Account Balance", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", + ChartType: malak.IntegrationChartTypeBar, + }, + } + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) + require.NoError(t, err) + + charts, err := integrationRepo.ListCharts(t.Context(), workspace.ID) + require.NoError(t, err) + require.Len(t, charts, 1) + createdChart := charts[0] + + dashboard := &malak.Dashboard{ + WorkspaceID: workspace.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "Test Dashboard", + Description: "Test Dashboard Description", + } + + err = dashboardRepo.Create(t.Context(), dashboard) + require.NoError(t, err) + require.Equal(t, int64(0), dashboard.ChartCount) + + chart := &malak.DashboardChart{ + WorkspaceIntegrationID: workspaceIntegration.ID, + ChartID: createdChart.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboardChart), + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + } + + err = dashboardRepo.AddChart(t.Context(), chart) + require.NoError(t, err) + require.NotEmpty(t, chart.ID) + + // verify chart count was incremented + updatedDashboard, err := dashboardRepo.Get(t.Context(), malak.FetchDashboardOption{ + WorkspaceID: workspace.ID, + Reference: dashboard.Reference, + }) + require.NoError(t, err) + require.Equal(t, int64(1), updatedDashboard.ChartCount) +} + +func TestDashboard_Get(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + dashboardRepo := NewDashboardRepo(client) + workspaceRepo := NewWorkspaceRepository(client) + + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), + }) + require.NoError(t, err) + + dashboard := &malak.Dashboard{ + WorkspaceID: workspace.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "Test Dashboard", + Description: "Test Dashboard Description", + } + + err = dashboardRepo.Create(t.Context(), dashboard) + require.NoError(t, err) + + tests := []struct { + name string + opts malak.FetchDashboardOption + expectedError error + }{ + { + name: "existing dashboard", + opts: malak.FetchDashboardOption{ + WorkspaceID: workspace.ID, + Reference: dashboard.Reference, + }, + expectedError: nil, + }, + { + name: "non-existent dashboard", + opts: malak.FetchDashboardOption{ + WorkspaceID: workspace.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + }, + expectedError: malak.ErrDashboardNotFound, + }, + { + name: "wrong workspace", + opts: malak.FetchDashboardOption{ + WorkspaceID: uuid.New(), + Reference: dashboard.Reference, + }, + expectedError: malak.ErrDashboardNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := dashboardRepo.Get(t.Context(), tt.opts) + if tt.expectedError != nil { + require.ErrorIs(t, err, tt.expectedError) + } else { + require.NoError(t, err) + require.Equal(t, tt.opts.WorkspaceID, result.WorkspaceID) + require.Equal(t, tt.opts.Reference, result.Reference) + require.Equal(t, dashboard.Title, result.Title) + require.Equal(t, dashboard.Description, result.Description) + } + }) + } +} + +func TestDashboard_GetCharts(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + dashboardRepo := NewDashboardRepo(client) + workspaceRepo := NewWorkspaceRepository(client) + integrationRepo := NewIntegrationRepo(client) + + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), + }) + require.NoError(t, err) + + integration := &malak.Integration{ + IntegrationName: "Mercury", + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), + Description: "Mercury Banking Integration", + IsEnabled: true, + IntegrationType: malak.IntegrationTypeOauth2, + LogoURL: "https://mercury.com/logo.png", + } + err = integrationRepo.Create(t.Context(), integration) + require.NoError(t, err) + + integrations, err := integrationRepo.List(t.Context(), workspace) + require.NoError(t, err) + require.Len(t, integrations, 1) + workspaceIntegration := integrations[0] + + chartValues := []malak.IntegrationChartValues{ + { + UserFacingName: "Account Balance", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", + ChartType: malak.IntegrationChartTypeBar, + }, + { + UserFacingName: "Transaction History", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccountTransaction, + ProviderID: "account_123", + ChartType: malak.IntegrationChartTypeBar, + }, + } + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) + require.NoError(t, err) + + createdCharts, err := integrationRepo.ListCharts(t.Context(), workspace.ID) + require.NoError(t, err) + require.Len(t, createdCharts, 2) + + dashboard := &malak.Dashboard{ + WorkspaceID: workspace.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "Test Dashboard", + Description: "Test Dashboard Description", + } + + err = dashboardRepo.Create(t.Context(), dashboard) + require.NoError(t, err) + + // Add multiple charts + charts := []*malak.DashboardChart{ + { + WorkspaceIntegrationID: workspaceIntegration.ID, + ChartID: createdCharts[0].ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboardChart), + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + }, + { + WorkspaceIntegrationID: workspaceIntegration.ID, + ChartID: createdCharts[1].ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboardChart), + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + }, + } + + for _, chart := range charts { + err = dashboardRepo.AddChart(t.Context(), chart) + require.NoError(t, err) + } + + // Test getting charts + tests := []struct { + name string + opts malak.FetchDashboardChartsOption + expectedCount int + }{ + { + name: "existing dashboard charts", + opts: malak.FetchDashboardChartsOption{ + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + }, + expectedCount: 2, + }, + { + name: "non-existent dashboard", + opts: malak.FetchDashboardChartsOption{ + WorkspaceID: workspace.ID, + DashboardID: uuid.New(), + }, + expectedCount: 0, + }, + { + name: "wrong workspace", + opts: malak.FetchDashboardChartsOption{ + WorkspaceID: uuid.New(), + DashboardID: dashboard.ID, + }, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, err := dashboardRepo.GetCharts(t.Context(), tt.opts) + require.NoError(t, err) + require.Equal(t, tt.expectedCount, len(results)) + + if tt.expectedCount > 0 { + for _, result := range results { + require.Equal(t, tt.opts.WorkspaceID, result.WorkspaceID) + require.Equal(t, tt.opts.DashboardID, result.DashboardID) + require.NotEmpty(t, result.ID) + require.NotEmpty(t, result.Reference) + } + } + }) + } +} + +func TestDashboard_List(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + dashboardRepo := NewDashboardRepo(client) + workspaceRepo := NewWorkspaceRepository(client) + + // Clean up any existing dashboards first + _, err := client.NewDelete(). + Table("dashboards"). + Where("1=1"). + Exec(t.Context()) + require.NoError(t, err) + + workspace1, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), + }) + require.NoError(t, err) + + workspace2, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("c12da796-9362-4c70-b2cb-fc8a1eba2526"), + }) + require.NoError(t, err) + + dashboards1 := []*malak.Dashboard{ + { + WorkspaceID: workspace1.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "First Dashboard", + Description: "First Dashboard Description", + }, + { + WorkspaceID: workspace1.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "Second Dashboard", + Description: "Second Dashboard Description", + }, + { + WorkspaceID: workspace1.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "Third Dashboard", + Description: "Third Dashboard Description", + }, + } + + dashboards2 := []*malak.Dashboard{ + { + WorkspaceID: workspace2.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "First Dashboard Workspace 2", + Description: "First Dashboard Description Workspace 2", + }, + { + WorkspaceID: workspace2.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "Second Dashboard Workspace 2", + Description: "Second Dashboard Description Workspace 2", + }, + } + + for _, d := range dashboards1 { + err = dashboardRepo.Create(t.Context(), d) + require.NoError(t, err) + require.NotEmpty(t, d.ID) + } + + for _, d := range dashboards2 { + err = dashboardRepo.Create(t.Context(), d) + require.NoError(t, err) + require.NotEmpty(t, d.ID) + } + + tests := []struct { + name string + opts malak.ListDashboardOptions + expectedCount int + totalCount int64 + }{ + { + name: "first page workspace 1", + opts: malak.ListDashboardOptions{ + WorkspaceID: workspace1.ID, + Paginator: malak.Paginator{ + Page: 1, + PerPage: 2, + }, + }, + expectedCount: 2, + totalCount: 3, + }, + { + name: "second page workspace 1", + opts: malak.ListDashboardOptions{ + WorkspaceID: workspace1.ID, + Paginator: malak.Paginator{ + Page: 2, + PerPage: 2, + }, + }, + expectedCount: 1, + totalCount: 3, + }, + { + name: "all items workspace 1", + opts: malak.ListDashboardOptions{ + WorkspaceID: workspace1.ID, + Paginator: malak.Paginator{ + Page: 1, + PerPage: 10, + }, + }, + expectedCount: 3, + totalCount: 3, + }, + { + name: "all items workspace 2", + opts: malak.ListDashboardOptions{ + WorkspaceID: workspace2.ID, + Paginator: malak.Paginator{ + Page: 1, + PerPage: 10, + }, + }, + expectedCount: 2, + totalCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, total, err := dashboardRepo.List(t.Context(), tt.opts) + require.NoError(t, err) + require.Equal(t, tt.expectedCount, len(results)) + require.Equal(t, tt.totalCount, total) + + for _, result := range results { + require.Equal(t, tt.opts.WorkspaceID, result.WorkspaceID) + } + }) + } + + nonExistentResults, total, err := dashboardRepo.List(t.Context(), malak.ListDashboardOptions{ + WorkspaceID: uuid.New(), + Paginator: malak.Paginator{ + Page: 1, + PerPage: 10, + }, + }) + require.NoError(t, err) + require.Equal(t, 0, len(nonExistentResults)) + require.Equal(t, int64(0), total) +} + +func TestDashboard_GetChartsWithIntegration(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + dashboardRepo := NewDashboardRepo(client) + workspaceRepo := NewWorkspaceRepository(client) + integrationRepo := NewIntegrationRepo(client) + + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), + }) + require.NoError(t, err) + + integration := &malak.Integration{ + IntegrationName: "Mercury", + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), + Description: "Mercury Banking Integration", + IsEnabled: true, + IntegrationType: malak.IntegrationTypeOauth2, + LogoURL: "https://mercury.com/logo.png", + } + err = integrationRepo.Create(t.Context(), integration) + require.NoError(t, err) + + integrations, err := integrationRepo.List(t.Context(), workspace) + require.NoError(t, err) + require.Len(t, integrations, 1) + workspaceIntegration := integrations[0] + + chartValues := []malak.IntegrationChartValues{ + { + UserFacingName: "Account Balance", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", + ChartType: malak.IntegrationChartTypeBar, + }, + { + UserFacingName: "Transaction History", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccountTransaction, + ProviderID: "account_123", + ChartType: malak.IntegrationChartTypeBar, + }, + } + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) + require.NoError(t, err) + + charts, err := integrationRepo.ListCharts(t.Context(), workspace.ID) + require.NoError(t, err) + require.Len(t, charts, 2) + + dashboard := &malak.Dashboard{ + WorkspaceID: workspace.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "Mercury Dashboard", + Description: "Mercury Banking Dashboard", + } + err = dashboardRepo.Create(t.Context(), dashboard) + require.NoError(t, err) + + for _, chart := range charts { + dashboardChart := &malak.DashboardChart{ + WorkspaceIntegrationID: workspaceIntegration.ID, + ChartID: chart.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboardChart), + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + } + err = dashboardRepo.AddChart(t.Context(), dashboardChart) + require.NoError(t, err) + } + + dashboardCharts, err := dashboardRepo.GetCharts(t.Context(), malak.FetchDashboardChartsOption{ + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + }) + require.NoError(t, err) + require.Len(t, dashboardCharts, 2) + + // verify integration chart data is loaded + for _, dashboardChart := range dashboardCharts { + require.NotNil(t, dashboardChart.IntegrationChart) + require.NotEmpty(t, dashboardChart.IntegrationChart.UserFacingName) + require.NotEmpty(t, dashboardChart.IntegrationChart.InternalName) + require.NotEmpty(t, dashboardChart.IntegrationChart.ChartType) + require.Equal(t, workspaceIntegration.ID, dashboardChart.IntegrationChart.WorkspaceIntegrationID) + require.Equal(t, workspace.ID, dashboardChart.IntegrationChart.WorkspaceID) + } + + // vErify specific chart data + foundAccountBalance := false + foundTransactionHistory := false + + for _, dashboardChart := range dashboardCharts { + switch dashboardChart.IntegrationChart.InternalName { + case malak.IntegrationChartInternalNameTypeMercuryAccount: + foundAccountBalance = true + require.Equal(t, "Account Balance", dashboardChart.IntegrationChart.UserFacingName) + require.Equal(t, malak.IntegrationChartTypeBar, dashboardChart.IntegrationChart.ChartType) + case malak.IntegrationChartInternalNameTypeMercuryAccountTransaction: + foundTransactionHistory = true + require.Equal(t, "Transaction History", dashboardChart.IntegrationChart.UserFacingName) + require.Equal(t, malak.IntegrationChartTypeBar, dashboardChart.IntegrationChart.ChartType) + } + } + + require.True(t, foundAccountBalance, "Account Balance chart not found") + require.True(t, foundTransactionHistory, "Transaction History chart not found") +} diff --git a/internal/datastore/postgres/deck_test.go b/internal/datastore/postgres/deck_test.go index f01499e5..e04b9f49 100644 --- a/internal/datastore/postgres/deck_test.go +++ b/internal/datastore/postgres/deck_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "testing" "github.com/ayinke-llc/malak" @@ -24,13 +23,13 @@ func TestDeck_Create(t *testing.T) { userRepo := NewUserRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) @@ -44,7 +43,7 @@ func TestDeck_Create(t *testing.T) { ObjectKey: uuid.NewString(), } - err = deck.Create(context.Background(), decks, opts) + err = deck.Create(t.Context(), decks, opts) require.NoError(t, err) } @@ -59,7 +58,7 @@ func TestDeck_List(t *testing.T) { userRepo := NewUserRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) @@ -67,17 +66,17 @@ func TestDeck_List(t *testing.T) { _ = user // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) - decks, err := deck.List(context.Background(), workspace) + decks, err := deck.List(t.Context(), workspace) require.NoError(t, err) require.Len(t, decks, 0) - err = deck.Create(context.Background(), &malak.Deck{ + err = deck.Create(t.Context(), &malak.Deck{ Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDeck), WorkspaceID: workspace.ID, CreatedBy: user.ID, @@ -90,7 +89,7 @@ func TestDeck_List(t *testing.T) { require.NoError(t, err) - decks, err = deck.List(context.Background(), workspace) + decks, err = deck.List(t.Context(), workspace) require.NoError(t, err) require.Len(t, decks, 1) @@ -107,7 +106,7 @@ func TestDeck_Get(t *testing.T) { userRepo := NewUserRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) @@ -115,12 +114,12 @@ func TestDeck_Get(t *testing.T) { _ = user // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) - _, err = deck.Get(context.Background(), malak.FetchDeckOptions{ + _, err = deck.Get(t.Context(), malak.FetchDeckOptions{ Reference: "oops", WorkspaceID: workspace.ID, }) @@ -129,7 +128,7 @@ func TestDeck_Get(t *testing.T) { ref := malak.NewReferenceGenerator().Generate(malak.EntityTypeDeck) - err = deck.Create(context.Background(), &malak.Deck{ + err = deck.Create(t.Context(), &malak.Deck{ Reference: ref, WorkspaceID: workspace.ID, CreatedBy: user.ID, @@ -142,7 +141,7 @@ func TestDeck_Get(t *testing.T) { require.NoError(t, err) - _, err = deck.Get(context.Background(), malak.FetchDeckOptions{ + _, err = deck.Get(t.Context(), malak.FetchDeckOptions{ Reference: ref.String(), WorkspaceID: workspace.ID, }) @@ -160,7 +159,7 @@ func TestDeck_Delete(t *testing.T) { userRepo := NewUserRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) @@ -168,14 +167,14 @@ func TestDeck_Delete(t *testing.T) { _ = user // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) ref := malak.NewReferenceGenerator().Generate(malak.EntityTypeDeck) - err = deck.Create(context.Background(), &malak.Deck{ + err = deck.Create(t.Context(), &malak.Deck{ Reference: ref, WorkspaceID: workspace.ID, CreatedBy: user.ID, @@ -188,15 +187,15 @@ func TestDeck_Delete(t *testing.T) { require.NoError(t, err) - deckFromDB, err := deck.Get(context.Background(), malak.FetchDeckOptions{ + deckFromDB, err := deck.Get(t.Context(), malak.FetchDeckOptions{ Reference: ref.String(), WorkspaceID: workspace.ID, }) require.NoError(t, err) - require.NoError(t, deck.Delete(context.Background(), deckFromDB)) + require.NoError(t, deck.Delete(t.Context(), deckFromDB)) - _, err = deck.Get(context.Background(), malak.FetchDeckOptions{ + _, err = deck.Get(t.Context(), malak.FetchDeckOptions{ Reference: ref.String(), WorkspaceID: workspace.ID, }) @@ -215,7 +214,7 @@ func TestDeck_UpdatePreferences(t *testing.T) { userRepo := NewUserRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) @@ -223,14 +222,14 @@ func TestDeck_UpdatePreferences(t *testing.T) { _ = user // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) ref := malak.NewReferenceGenerator().Generate(malak.EntityTypeDeck) - err = deck.Create(context.Background(), &malak.Deck{ + err = deck.Create(t.Context(), &malak.Deck{ Reference: ref, WorkspaceID: workspace.ID, CreatedBy: user.ID, @@ -245,7 +244,7 @@ func TestDeck_UpdatePreferences(t *testing.T) { require.NoError(t, err) - deckFromDB, err := deck.Get(context.Background(), malak.FetchDeckOptions{ + deckFromDB, err := deck.Get(t.Context(), malak.FetchDeckOptions{ Reference: ref.String(), WorkspaceID: workspace.ID, }) @@ -254,9 +253,9 @@ func TestDeck_UpdatePreferences(t *testing.T) { deckFromDB.DeckPreference.RequireEmail = true deckFromDB.DeckPreference.EnableDownloading = true - require.NoError(t, deck.UpdatePreferences(context.Background(), deckFromDB)) + require.NoError(t, deck.UpdatePreferences(t.Context(), deckFromDB)) - deckFromDatabase, err := deck.Get(context.Background(), malak.FetchDeckOptions{ + deckFromDatabase, err := deck.Get(t.Context(), malak.FetchDeckOptions{ Reference: ref.String(), WorkspaceID: workspace.ID, }) @@ -275,13 +274,13 @@ func TestDeck_ToggleArchive(t *testing.T) { userRepo := NewUserRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) @@ -289,7 +288,7 @@ func TestDeck_ToggleArchive(t *testing.T) { ref := malak.NewReferenceGenerator().Generate(malak.EntityTypeDeck) // Create a new deck - err = deck.Create(context.Background(), &malak.Deck{ + err = deck.Create(t.Context(), &malak.Deck{ Reference: ref, WorkspaceID: workspace.ID, CreatedBy: user.ID, @@ -302,7 +301,7 @@ func TestDeck_ToggleArchive(t *testing.T) { require.NoError(t, err) // Get the deck - deckFromDB, err := deck.Get(context.Background(), malak.FetchDeckOptions{ + deckFromDB, err := deck.Get(t.Context(), malak.FetchDeckOptions{ Reference: ref.String(), WorkspaceID: workspace.ID, }) @@ -310,11 +309,11 @@ func TestDeck_ToggleArchive(t *testing.T) { require.False(t, deckFromDB.IsArchived) // Toggle archive (true) - err = deck.ToggleArchive(context.Background(), deckFromDB) + err = deck.ToggleArchive(t.Context(), deckFromDB) require.NoError(t, err) // Verify it's archived - deckFromDB, err = deck.Get(context.Background(), malak.FetchDeckOptions{ + deckFromDB, err = deck.Get(t.Context(), malak.FetchDeckOptions{ Reference: ref.String(), WorkspaceID: workspace.ID, }) @@ -322,11 +321,11 @@ func TestDeck_ToggleArchive(t *testing.T) { require.True(t, deckFromDB.IsArchived) // Toggle archive again (false) - err = deck.ToggleArchive(context.Background(), deckFromDB) + err = deck.ToggleArchive(t.Context(), deckFromDB) require.NoError(t, err) // Verify it's unarchived - deckFromDB, err = deck.Get(context.Background(), malak.FetchDeckOptions{ + deckFromDB, err = deck.Get(t.Context(), malak.FetchDeckOptions{ Reference: ref.String(), WorkspaceID: workspace.ID, }) @@ -345,13 +344,13 @@ func TestDecks_TogglePinned(t *testing.T) { deck := NewDeckRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) @@ -372,7 +371,7 @@ func TestDecks_TogglePinned(t *testing.T) { ObjectKey: uuid.NewString(), } - err = deck.Create(context.Background(), decks, opts) + err = deck.Create(t.Context(), decks, opts) require.NoError(t, err) } @@ -390,11 +389,11 @@ func TestDecks_TogglePinned(t *testing.T) { } // create another without pinning - err = deck.Create(context.Background(), decks, opts) + err = deck.Create(t.Context(), decks, opts) require.NoError(t, err) // cannot add a 5th pinned item - err = deck.TogglePinned(context.Background(), decks) + err = deck.TogglePinned(t.Context(), decks) require.Error(t, err) require.Equal(t, malak.ErrPinnedDeckCapacityExceeded, err) } @@ -408,26 +407,26 @@ func TestDeck_PublicDetails(t *testing.T) { userRepo := NewUserRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) // Test non-existent deck nonExistentRef := malak.NewReferenceGenerator().Generate(malak.EntityTypeDeck) - _, err = deck.PublicDetails(context.Background(), nonExistentRef) + _, err = deck.PublicDetails(t.Context(), nonExistentRef) require.Error(t, err) require.ErrorIs(t, err, malak.ErrDeckNotFound) // Create a new deck ref := malak.NewReferenceGenerator().Generate(malak.EntityTypeDeck) - err = deck.Create(context.Background(), &malak.Deck{ + err = deck.Create(t.Context(), &malak.Deck{ Reference: ref, WorkspaceID: workspace.ID, CreatedBy: user.ID, @@ -449,7 +448,7 @@ func TestDeck_PublicDetails(t *testing.T) { require.NoError(t, err) // Test fetching the deck's public details - deckFromDB, err := deck.PublicDetails(context.Background(), ref) + deckFromDB, err := deck.PublicDetails(t.Context(), ref) require.NoError(t, err) require.NotNil(t, deckFromDB) require.Equal(t, ref.String(), deckFromDB.Reference.String()) diff --git a/internal/datastore/postgres/integration.go b/internal/datastore/postgres/integration.go index 920be139..dfd8fa80 100644 --- a/internal/datastore/postgres/integration.go +++ b/internal/datastore/postgres/integration.go @@ -175,6 +175,7 @@ func (i *integrationRepo) CreateCharts(ctx context.Context, Metadata: malak.IntegrationChartMetadata{ ProviderID: value.ProviderID, }, + ChartType: value.ChartType, } _, err := tx.NewInsert().Model(chart). @@ -238,3 +239,38 @@ func (i *integrationRepo) AddDataPoint(ctx context.Context, return nil }) } + +func (i *integrationRepo) ListCharts(ctx context.Context, + workspaceID uuid.UUID) ([]malak.IntegrationChart, error) { + + ctx, cancelFn := withContext(ctx) + defer cancelFn() + + charts := make([]malak.IntegrationChart, 0) + + return charts, i.inner.NewSelect(). + Model(&charts). + Where("workspace_id = ?", workspaceID). + Order("created_at ASC"). + Scan(ctx) +} + +func (i *integrationRepo) GetChart(ctx context.Context, + opts malak.FetchChartOptions) (malak.IntegrationChart, error) { + + ctx, cancelFn := withContext(ctx) + defer cancelFn() + + chart := malak.IntegrationChart{} + + err := i.inner.NewSelect(). + Model(&chart). + Where("workspace_id = ?", opts.WorkspaceID). + Where("reference = ?", opts.Reference). + Scan(ctx) + if errors.Is(err, sql.ErrNoRows) { + err = malak.ErrChartNotFound + } + + return chart, err +} diff --git a/internal/datastore/postgres/integration_test.go b/internal/datastore/postgres/integration_test.go index 94ef35be..28a7c56d 100644 --- a/internal/datastore/postgres/integration_test.go +++ b/internal/datastore/postgres/integration_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "testing" "github.com/ayinke-llc/malak" @@ -16,7 +15,7 @@ func TestIntegration_Create(t *testing.T) { integrationRepo := NewIntegrationRepo(client) - err := integrationRepo.Create(context.Background(), &malak.Integration{ + err := integrationRepo.Create(t.Context(), &malak.Integration{ IntegrationName: "Stripe", Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), Description: "Stripe stripe stripe", @@ -36,16 +35,16 @@ func TestIntegration_List(t *testing.T) { repo := NewWorkspaceRepository(client) - workspace, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) - integrations, err := integrationRepo.List(context.Background(), workspace) + integrations, err := integrationRepo.List(t.Context(), workspace) require.NoError(t, err) require.Len(t, integrations, 0) - err = integrationRepo.Create(context.Background(), &malak.Integration{ + err = integrationRepo.Create(t.Context(), &malak.Integration{ IntegrationName: "Stripe", Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), Description: "Stripe stripe stripe", @@ -55,7 +54,7 @@ func TestIntegration_List(t *testing.T) { }) require.NoError(t, err) - integrations, err = integrationRepo.List(context.Background(), workspace) + integrations, err = integrationRepo.List(t.Context(), workspace) require.NoError(t, err) require.Len(t, integrations, 1) } @@ -67,11 +66,11 @@ func TestIntegration_System(t *testing.T) { integrationRepo := NewIntegrationRepo(client) - integrations, err := integrationRepo.System(context.Background()) + integrations, err := integrationRepo.System(t.Context()) require.NoError(t, err) require.Len(t, integrations, 0) - err = integrationRepo.Create(context.Background(), &malak.Integration{ + err = integrationRepo.Create(t.Context(), &malak.Integration{ IntegrationName: "Stripe", Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), Description: "Stripe stripe stripe", @@ -81,7 +80,7 @@ func TestIntegration_System(t *testing.T) { }) require.NoError(t, err) - integrations, err = integrationRepo.System(context.Background()) + integrations, err = integrationRepo.System(t.Context()) require.NoError(t, err) require.Len(t, integrations, 1) } @@ -94,22 +93,22 @@ func TestIntegration_Get(t *testing.T) { integrationRepo := NewIntegrationRepo(client) repo := NewWorkspaceRepository(client) - _, err := integrationRepo.Get(context.Background(), malak.FindWorkspaceIntegrationOptions{ + _, err := integrationRepo.Get(t.Context(), malak.FindWorkspaceIntegrationOptions{ Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeWorkspaceIntegration), }) require.Error(t, err) require.Equal(t, malak.ErrWorkspaceIntegrationNotFound, err) - workspace, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) - integrations, err := integrationRepo.List(context.Background(), workspace) + integrations, err := integrationRepo.List(t.Context(), workspace) require.NoError(t, err) require.Len(t, integrations, 0) - err = integrationRepo.Create(context.Background(), &malak.Integration{ + err = integrationRepo.Create(t.Context(), &malak.Integration{ IntegrationName: "Stripe", Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), Description: "Stripe stripe stripe", @@ -119,16 +118,16 @@ func TestIntegration_Get(t *testing.T) { }) require.NoError(t, err) - integrations, err = integrationRepo.List(context.Background(), workspace) + integrations, err = integrationRepo.List(t.Context(), workspace) require.NoError(t, err) require.Len(t, integrations, 1) - _, err = integrationRepo.Get(context.Background(), malak.FindWorkspaceIntegrationOptions{ + _, err = integrationRepo.Get(t.Context(), malak.FindWorkspaceIntegrationOptions{ Reference: integrations[0].Reference, }) require.NoError(t, err) - _, err = integrationRepo.Get(context.Background(), malak.FindWorkspaceIntegrationOptions{ + _, err = integrationRepo.Get(t.Context(), malak.FindWorkspaceIntegrationOptions{ Reference: integrations[0].Reference, ID: integrations[0].ID, }) @@ -142,13 +141,12 @@ func TestIntegration_Disable(t *testing.T) { integrationRepo := NewIntegrationRepo(client) repo := NewWorkspaceRepository(client) - workspace, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) - // Create a test integration - err = integrationRepo.Create(context.Background(), &malak.Integration{ + err = integrationRepo.Create(t.Context(), &malak.Integration{ IntegrationName: "Stripe", Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), Description: "Stripe stripe stripe", @@ -158,26 +156,25 @@ func TestIntegration_Disable(t *testing.T) { }) require.NoError(t, err) - // Get the workspace integration - integrations, err := integrationRepo.List(context.Background(), workspace) + integrations, err := integrationRepo.List(t.Context(), workspace) require.NoError(t, err) require.Len(t, integrations, 1) workspaceIntegration := integrations[0] workspaceIntegration.IsEnabled = true - require.NoError(t, integrationRepo.Update(context.Background(), &workspaceIntegration)) + require.NoError(t, integrationRepo.Update(t.Context(), &workspaceIntegration)) - updatedIntegration, err := integrationRepo.Get(context.Background(), malak.FindWorkspaceIntegrationOptions{ + updatedIntegration, err := integrationRepo.Get(t.Context(), malak.FindWorkspaceIntegrationOptions{ Reference: workspaceIntegration.Reference, }) require.NoError(t, err) require.True(t, updatedIntegration.IsEnabled) - err = integrationRepo.Disable(context.Background(), &workspaceIntegration) + err = integrationRepo.Disable(t.Context(), &workspaceIntegration) require.NoError(t, err) - updatedIntegration, err = integrationRepo.Get(context.Background(), malak.FindWorkspaceIntegrationOptions{ + updatedIntegration, err = integrationRepo.Get(t.Context(), malak.FindWorkspaceIntegrationOptions{ Reference: workspaceIntegration.Reference, }) require.NoError(t, err) @@ -191,13 +188,13 @@ func TestIntegration_Update(t *testing.T) { integrationRepo := NewIntegrationRepo(client) repo := NewWorkspaceRepository(client) - workspace, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) // Create a test integration - err = integrationRepo.Create(context.Background(), &malak.Integration{ + err = integrationRepo.Create(t.Context(), &malak.Integration{ IntegrationName: "Stripe", Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), Description: "Stripe stripe stripe", @@ -208,25 +205,40 @@ func TestIntegration_Update(t *testing.T) { require.NoError(t, err) // Get the workspace integration - integrations, err := integrationRepo.List(context.Background(), workspace) + integrations, err := integrationRepo.List(t.Context(), workspace) require.NoError(t, err) require.Len(t, integrations, 1) workspaceIntegration := integrations[0] - initialUpdateTime := workspaceIntegration.UpdatedAt + + // Verify initial state + require.False(t, workspaceIntegration.IsEnabled) // Update the integration workspaceIntegration.IsEnabled = true - err = integrationRepo.Update(context.Background(), &workspaceIntegration) + err = integrationRepo.Update(t.Context(), &workspaceIntegration) require.NoError(t, err) // Fetch updated integration - updatedIntegration, err := integrationRepo.Get(context.Background(), malak.FindWorkspaceIntegrationOptions{ + updatedIntegration, err := integrationRepo.Get(t.Context(), malak.FindWorkspaceIntegrationOptions{ Reference: workspaceIntegration.Reference, + ID: workspaceIntegration.ID, }) require.NoError(t, err) require.True(t, updatedIntegration.IsEnabled) - require.True(t, updatedIntegration.UpdatedAt.After(initialUpdateTime)) + + // Update again with different value + updatedIntegration.IsEnabled = false + err = integrationRepo.Update(t.Context(), updatedIntegration) + require.NoError(t, err) + + // Verify the second update + finalIntegration, err := integrationRepo.Get(t.Context(), malak.FindWorkspaceIntegrationOptions{ + Reference: workspaceIntegration.Reference, + ID: workspaceIntegration.ID, + }) + require.NoError(t, err) + require.False(t, finalIntegration.IsEnabled) } func TestIntegration_CreateCharts(t *testing.T) { @@ -236,12 +248,12 @@ func TestIntegration_CreateCharts(t *testing.T) { integrationRepo := NewIntegrationRepo(client) repo := NewWorkspaceRepository(client) - workspace, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) - err = integrationRepo.Create(context.Background(), &malak.Integration{ + err = integrationRepo.Create(t.Context(), &malak.Integration{ IntegrationName: "Stripe", Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), Description: "Stripe stripe stripe", @@ -251,7 +263,7 @@ func TestIntegration_CreateCharts(t *testing.T) { }) require.NoError(t, err) - integrations, err := integrationRepo.List(context.Background(), workspace) + integrations, err := integrationRepo.List(t.Context(), workspace) require.NoError(t, err) require.Len(t, integrations, 1) @@ -262,23 +274,25 @@ func TestIntegration_CreateCharts(t *testing.T) { UserFacingName: "Revenue Chart", InternalName: "revenue_chart", ProviderID: "stripe_revenue", + ChartType: malak.IntegrationChartTypeBar, }, { UserFacingName: "Customer Growth", InternalName: "customer_growth", ProviderID: "stripe_customers", + ChartType: malak.IntegrationChartTypeBar, }, } - err = integrationRepo.CreateCharts(context.Background(), &workspaceIntegration, chartValues) + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) require.NoError(t, err) - _, err = integrationRepo.Get(context.Background(), malak.FindWorkspaceIntegrationOptions{ + _, err = integrationRepo.Get(t.Context(), malak.FindWorkspaceIntegrationOptions{ Reference: workspaceIntegration.Reference, }) require.NoError(t, err) - err = integrationRepo.CreateCharts(context.Background(), &workspaceIntegration, chartValues) + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) require.NoError(t, err) } @@ -289,12 +303,12 @@ func TestIntegration_AddDataPoint(t *testing.T) { integrationRepo := NewIntegrationRepo(client) repo := NewWorkspaceRepository(client) - workspace, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) - err = integrationRepo.Create(context.Background(), &malak.Integration{ + err = integrationRepo.Create(t.Context(), &malak.Integration{ IntegrationName: "Stripe", Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), Description: "Stripe stripe stripe", @@ -304,7 +318,7 @@ func TestIntegration_AddDataPoint(t *testing.T) { }) require.NoError(t, err) - integrations, err := integrationRepo.List(context.Background(), workspace) + integrations, err := integrationRepo.List(t.Context(), workspace) require.NoError(t, err) require.Len(t, integrations, 1) @@ -315,10 +329,11 @@ func TestIntegration_AddDataPoint(t *testing.T) { UserFacingName: "Revenue Chart", InternalName: "revenue_chart", ProviderID: "stripe_revenue", + ChartType: malak.IntegrationChartTypeBar, }, } - err = integrationRepo.CreateCharts(context.Background(), &workspaceIntegration, chartValues) + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) require.NoError(t, err) dataPoints := []malak.IntegrationDataValues{ @@ -334,7 +349,7 @@ func TestIntegration_AddDataPoint(t *testing.T) { }, } - err = integrationRepo.AddDataPoint(context.Background(), &workspaceIntegration, dataPoints) + err = integrationRepo.AddDataPoint(t.Context(), &workspaceIntegration, dataPoints) require.NoError(t, err) invalidDataPoints := []malak.IntegrationDataValues{ @@ -350,6 +365,285 @@ func TestIntegration_AddDataPoint(t *testing.T) { }, } - err = integrationRepo.AddDataPoint(context.Background(), &workspaceIntegration, invalidDataPoints) + err = integrationRepo.AddDataPoint(t.Context(), &workspaceIntegration, invalidDataPoints) + require.Error(t, err) +} + +func TestIntegration_ListCharts(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + integrationRepo := NewIntegrationRepo(client) + repo := NewWorkspaceRepository(client) + + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), + }) + require.NoError(t, err) + + // Initially there should be no charts + charts, err := integrationRepo.ListCharts(t.Context(), workspace.ID) + require.NoError(t, err) + require.Empty(t, charts) + + err = integrationRepo.Create(t.Context(), &malak.Integration{ + IntegrationName: "Stripe", + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), + Description: "Stripe stripe stripe", + IsEnabled: true, + IntegrationType: malak.IntegrationTypeOauth2, + LogoURL: "https://google.com", + }) + require.NoError(t, err) + + integrations, err := integrationRepo.List(t.Context(), workspace) + require.NoError(t, err) + require.Len(t, integrations, 1) + + workspaceIntegration := integrations[0] + + chartValues := []malak.IntegrationChartValues{ + { + UserFacingName: "Monthly Revenue", + InternalName: "monthly_revenue", + ProviderID: "stripe_monthly_revenue", + ChartType: malak.IntegrationChartTypeBar, + }, + { + UserFacingName: "Customer Count", + InternalName: "customer_count", + ProviderID: "stripe_customer_count", + ChartType: malak.IntegrationChartTypeBar, + }, + } + + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) + require.NoError(t, err) + + charts, err = integrationRepo.ListCharts(t.Context(), workspace.ID) + require.NoError(t, err) + require.Len(t, charts, 2) + + require.Contains(t, []string{charts[0].UserFacingName, charts[1].UserFacingName}, "Monthly Revenue") + require.Contains(t, []string{charts[0].UserFacingName, charts[1].UserFacingName}, "Customer Count") + require.Contains(t, []string{string(charts[0].InternalName), string(charts[1].InternalName)}, "monthly_revenue") + require.Contains(t, []string{string(charts[0].InternalName), string(charts[1].InternalName)}, "customer_count") + + // verify workspace association + for _, chart := range charts { + require.Equal(t, workspace.ID, chart.WorkspaceID) + require.Equal(t, workspaceIntegration.ID, chart.WorkspaceIntegrationID) + require.NotEmpty(t, chart.Reference) + } +} + +func TestIntegration_GetChart(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + integrationRepo := NewIntegrationRepo(client) + repo := NewWorkspaceRepository(client) + + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), + }) + require.NoError(t, err) + + _, err = integrationRepo.GetChart(t.Context(), malak.FetchChartOptions{ + WorkspaceID: workspace.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegrationChart), + }) require.Error(t, err) + require.ErrorIs(t, err, malak.ErrChartNotFound) + + integration := &malak.Integration{ + IntegrationName: "Mercury", + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), + Description: "Mercury Banking Integration", + IsEnabled: true, + IntegrationType: malak.IntegrationTypeOauth2, + LogoURL: "https://mercury.com/logo.png", + } + err = integrationRepo.Create(t.Context(), integration) + require.NoError(t, err) + + integrations, err := integrationRepo.List(t.Context(), workspace) + require.NoError(t, err) + require.Len(t, integrations, 1) + workspaceIntegration := integrations[0] + + chartValues := []malak.IntegrationChartValues{ + { + UserFacingName: "Account Balance", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", + ChartType: malak.IntegrationChartTypeBar, + }, + } + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) + require.NoError(t, err) + + charts, err := integrationRepo.ListCharts(t.Context(), workspace.ID) + require.NoError(t, err) + require.Len(t, charts, 1) + + // Test getting chart by reference + chart, err := integrationRepo.GetChart(t.Context(), malak.FetchChartOptions{ + WorkspaceID: workspace.ID, + Reference: charts[0].Reference, + }) + require.NoError(t, err) + require.Equal(t, "Account Balance", chart.UserFacingName) + require.Equal(t, malak.IntegrationChartInternalNameTypeMercuryAccount, chart.InternalName) + require.Equal(t, malak.IntegrationChartTypeBar, chart.ChartType) + require.Equal(t, "account_123", chart.Metadata.ProviderID) + + // Test wrong workspace ID + _, err = integrationRepo.GetChart(t.Context(), malak.FetchChartOptions{ + WorkspaceID: uuid.New(), + Reference: charts[0].Reference, + }) + require.Error(t, err) + require.ErrorIs(t, err, malak.ErrChartNotFound) +} + +func TestIntegration_CreateChartsDuplicate(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + integrationRepo := NewIntegrationRepo(client) + repo := NewWorkspaceRepository(client) + + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), + }) + require.NoError(t, err) + + integration := &malak.Integration{ + IntegrationName: "Mercury", + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), + Description: "Mercury Banking Integration", + IsEnabled: true, + IntegrationType: malak.IntegrationTypeOauth2, + LogoURL: "https://mercury.com/logo.png", + } + err = integrationRepo.Create(t.Context(), integration) + require.NoError(t, err) + + integrations, err := integrationRepo.List(t.Context(), workspace) + require.NoError(t, err) + require.Len(t, integrations, 1) + workspaceIntegration := integrations[0] + + // Create charts with duplicate values + chartValues := []malak.IntegrationChartValues{ + { + UserFacingName: "Account Balance", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", + ChartType: malak.IntegrationChartTypeBar, + }, + { + UserFacingName: "Account Balance", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", + ChartType: malak.IntegrationChartTypeBar, + }, + } + + // First creation should succeed + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) + require.NoError(t, err) + + // Second creation should not create duplicates + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) + require.NoError(t, err) + + charts, err := integrationRepo.ListCharts(t.Context(), workspace.ID) + require.NoError(t, err) + require.Len(t, charts, 1) +} + +func TestIntegration_AddDataPointErrors(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + integrationRepo := NewIntegrationRepo(client) + repo := NewWorkspaceRepository(client) + + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), + }) + require.NoError(t, err) + + // Create integration and chart + integration := &malak.Integration{ + IntegrationName: "Mercury", + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), + Description: "Mercury Banking Integration", + IsEnabled: true, + IntegrationType: malak.IntegrationTypeOauth2, + LogoURL: "https://mercury.com/logo.png", + } + err = integrationRepo.Create(t.Context(), integration) + require.NoError(t, err) + + integrations, err := integrationRepo.List(t.Context(), workspace) + require.NoError(t, err) + require.Len(t, integrations, 1) + workspaceIntegration := integrations[0] + + // Try to add data point for non-existent chart + dataPoints := []malak.IntegrationDataValues{ + { + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", + Data: malak.IntegrationDataPoint{ + PointName: "Balance", + PointValue: 1000, + DataPointType: malak.IntegrationDataPointTypeCurrency, + }, + }, + } + + err = integrationRepo.AddDataPoint(t.Context(), &workspaceIntegration, dataPoints) + require.Error(t, err) // Should fail because chart doesn't exist + + // Create chart + chartValues := []malak.IntegrationChartValues{ + { + UserFacingName: "Account Balance", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", + ChartType: malak.IntegrationChartTypeBar, + }, + } + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) + require.NoError(t, err) + + // Try to add data point with wrong provider ID + dataPoints[0].ProviderID = "wrong_account" + err = integrationRepo.AddDataPoint(t.Context(), &workspaceIntegration, dataPoints) + require.Error(t, err) // Should fail because provider ID doesn't match + + // Try to add data point with wrong workspace integration + wrongWorkspaceIntegration := workspaceIntegration + wrongWorkspaceIntegration.ID = uuid.New() + err = integrationRepo.AddDataPoint(t.Context(), &wrongWorkspaceIntegration, dataPoints) + require.Error(t, err) +} + +func TestIntegration_ListChartsErrors(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + integrationRepo := NewIntegrationRepo(client) + + charts, err := integrationRepo.ListCharts(t.Context(), uuid.New()) + require.NoError(t, err) + require.Empty(t, charts) + + charts, err = integrationRepo.ListCharts(t.Context(), uuid.Nil) + require.NoError(t, err) + require.Empty(t, charts) } diff --git a/internal/datastore/postgres/migrations/20250214212029_add_extra_fields_to_dashboard_table.down.sql b/internal/datastore/postgres/migrations/20250214212029_add_extra_fields_to_dashboard_table.down.sql new file mode 100644 index 00000000..105db78c --- /dev/null +++ b/internal/datastore/postgres/migrations/20250214212029_add_extra_fields_to_dashboard_table.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE dashboards + DROP COLUMN description, + DROP COLUMN chart_count, + DROP COLUMN created_at, + DROP COLUMN updated_at, + DROP COLUMN deleted_at; diff --git a/internal/datastore/postgres/migrations/20250214212029_add_extra_fields_to_dashboard_table.up.sql b/internal/datastore/postgres/migrations/20250214212029_add_extra_fields_to_dashboard_table.up.sql new file mode 100644 index 00000000..922a8a33 --- /dev/null +++ b/internal/datastore/postgres/migrations/20250214212029_add_extra_fields_to_dashboard_table.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE dashboards + ADD COLUMN description TEXT NOT NULL, + ADD COLUMN chart_count SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE; diff --git a/internal/datastore/postgres/migrations/20250215120421_create_dashboard_chart_table.down.sql b/internal/datastore/postgres/migrations/20250215120421_create_dashboard_chart_table.down.sql new file mode 100644 index 00000000..4ff82124 --- /dev/null +++ b/internal/datastore/postgres/migrations/20250215120421_create_dashboard_chart_table.down.sql @@ -0,0 +1 @@ +DROP TABLE dashboard_charts; diff --git a/internal/datastore/postgres/migrations/20250215120421_create_dashboard_chart_table.up.sql b/internal/datastore/postgres/migrations/20250215120421_create_dashboard_chart_table.up.sql new file mode 100644 index 00000000..07ecd775 --- /dev/null +++ b/internal/datastore/postgres/migrations/20250215120421_create_dashboard_chart_table.up.sql @@ -0,0 +1,18 @@ +CREATE TYPE dashboard_chart_type AS ENUM('barchart','piechart'); + +CREATE TABLE dashboard_charts ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + workspace_integration_id uuid NOT NULL REFERENCES workspace_integrations(id), + workspace_id uuid NOT NULL REFERENCES workspaces(id), + dashboard_id uuid NOT NULL REFERENCES dashboards(id), + dashboard_type dashboard_chart_type NOT NULL, + reference VARCHAR (220) UNIQUE NOT NULL, + + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE +); + +ALTER TABLE dashboard_charts ADD CONSTRAINT dashboard_chart_reference_check_key CHECK (reference ~ 'dashboard_chart_[a-zA-Z0-9._]+'); + +ALTER TABLE dashboard_charts ADD CONSTRAINT unique_chart_per_dashboard UNIQUE(workspace_integration_id,dashboard_id,workspace_id); diff --git a/internal/datastore/postgres/migrations/20250220111140_add_title_to_dashboards.down.sql b/internal/datastore/postgres/migrations/20250220111140_add_title_to_dashboards.down.sql new file mode 100644 index 00000000..1994d982 --- /dev/null +++ b/internal/datastore/postgres/migrations/20250220111140_add_title_to_dashboards.down.sql @@ -0,0 +1 @@ +ALTER TABLE dashboards DROP COLUMN title; diff --git a/internal/datastore/postgres/migrations/20250220111140_add_title_to_dashboards.up.sql b/internal/datastore/postgres/migrations/20250220111140_add_title_to_dashboards.up.sql new file mode 100644 index 00000000..0f5cfe87 --- /dev/null +++ b/internal/datastore/postgres/migrations/20250220111140_add_title_to_dashboards.up.sql @@ -0,0 +1 @@ +ALTER TABLE dashboards ADD COLUMN title VARCHAR(255) NOT NULL; diff --git a/internal/datastore/postgres/migrations/20250220113938_add_workspace_id_to_dashboard.down.sql b/internal/datastore/postgres/migrations/20250220113938_add_workspace_id_to_dashboard.down.sql new file mode 100644 index 00000000..416ac8c0 --- /dev/null +++ b/internal/datastore/postgres/migrations/20250220113938_add_workspace_id_to_dashboard.down.sql @@ -0,0 +1 @@ +ALTER TABLE dashboards DROP COLUMN workspace_id; diff --git a/internal/datastore/postgres/migrations/20250220113938_add_workspace_id_to_dashboard.up.sql b/internal/datastore/postgres/migrations/20250220113938_add_workspace_id_to_dashboard.up.sql new file mode 100644 index 00000000..e7303ac8 --- /dev/null +++ b/internal/datastore/postgres/migrations/20250220113938_add_workspace_id_to_dashboard.up.sql @@ -0,0 +1 @@ +ALTER TABLE dashboards ADD COLUMN workspace_id uuid NOT NULL REFERENCES workspaces(id); diff --git a/internal/datastore/postgres/migrations/20250220161732_add_charttype_to_integration_charts.down.sql b/internal/datastore/postgres/migrations/20250220161732_add_charttype_to_integration_charts.down.sql new file mode 100644 index 00000000..ee741538 --- /dev/null +++ b/internal/datastore/postgres/migrations/20250220161732_add_charttype_to_integration_charts.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE integration_charts DROP COLUMN chart_type; + +ALTER TABLE dashboard_charts ADD COLUMN dashboard_type dashboard_chart_type NOT NULL; diff --git a/internal/datastore/postgres/migrations/20250220161732_add_charttype_to_integration_charts.up.sql b/internal/datastore/postgres/migrations/20250220161732_add_charttype_to_integration_charts.up.sql new file mode 100644 index 00000000..ae251297 --- /dev/null +++ b/internal/datastore/postgres/migrations/20250220161732_add_charttype_to_integration_charts.up.sql @@ -0,0 +1,5 @@ +CREATE TYPE chart_type AS ENUM('bar', 'pie'); + +ALTER TABLE integration_charts ADD COLUMN chart_type chart_type NOT NULL; + +ALTER TABLE dashboard_charts DROP COLUMN dashboard_type; diff --git a/internal/datastore/postgres/migrations/20250220215457_add_chartid_to_dashboard_charts.down.sql b/internal/datastore/postgres/migrations/20250220215457_add_chartid_to_dashboard_charts.down.sql new file mode 100644 index 00000000..dd21f7d2 --- /dev/null +++ b/internal/datastore/postgres/migrations/20250220215457_add_chartid_to_dashboard_charts.down.sql @@ -0,0 +1 @@ +ALTER TABLE dashboard_charts ADD CONSTRAINT unique_chart_per_dashboard UNIQUE(workspace_integration_id,dashboard_id,workspace_id); diff --git a/internal/datastore/postgres/migrations/20250220215457_add_chartid_to_dashboard_charts.up.sql b/internal/datastore/postgres/migrations/20250220215457_add_chartid_to_dashboard_charts.up.sql new file mode 100644 index 00000000..31096aef --- /dev/null +++ b/internal/datastore/postgres/migrations/20250220215457_add_chartid_to_dashboard_charts.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE dashboard_charts DROP CONSTRAINT unique_chart_per_dashboard; + +ALTER TABLE dashboard_charts ADD COLUMN chart_id uuid NOT NULL REFERENCES integration_charts(id); + +ALTER TABLE dashboard_charts ADD CONSTRAINT unique_chart_per_dashboard UNIQUE(workspace_integration_id,dashboard_id,workspace_id,chart_id); diff --git a/internal/datastore/postgres/plan_test.go b/internal/datastore/postgres/plan_test.go index 5adc3465..cbee80cf 100644 --- a/internal/datastore/postgres/plan_test.go +++ b/internal/datastore/postgres/plan_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "testing" "github.com/ayinke-llc/malak" @@ -15,7 +14,7 @@ func TestPlan_List(t *testing.T) { plan := NewPlanRepository(client) - plans, err := plan.List(context.Background()) + plans, err := plan.List(t.Context()) require.NoError(t, err) require.Len(t, plans, 2) @@ -28,12 +27,12 @@ func TestPlan_Get(t *testing.T) { plan := NewPlanRepository(client) - _, err := plan.Get(context.Background(), &malak.FetchPlanOptions{ + _, err := plan.Get(t.Context(), &malak.FetchPlanOptions{ Reference: "prod_QmtErtydaJZymT", }) require.NoError(t, err) - _, err = plan.Get(context.Background(), &malak.FetchPlanOptions{ + _, err = plan.Get(t.Context(), &malak.FetchPlanOptions{ Reference: "prod_QmtErtyda", }) require.Error(t, err) @@ -47,23 +46,23 @@ func TestPlan_SetDefault(t *testing.T) { planRepo := NewPlanRepository(client) - plan, err := planRepo.Get(context.Background(), &malak.FetchPlanOptions{ + plan, err := planRepo.Get(t.Context(), &malak.FetchPlanOptions{ Reference: "prod_QmtErtydaJZymT", }) require.NoError(t, err) require.NotNil(t, plan) require.False(t, plan.IsDefault) - secondPlan, err := planRepo.Get(context.Background(), &malak.FetchPlanOptions{ + secondPlan, err := planRepo.Get(t.Context(), &malak.FetchPlanOptions{ Reference: "prod_QmtFLR9JvXLryD", }) require.NoError(t, err) require.NotNil(t, secondPlan) require.False(t, secondPlan.IsDefault) - require.NoError(t, planRepo.SetDefault(context.Background(), plan)) + require.NoError(t, planRepo.SetDefault(t.Context(), plan)) - plan1FromDB, err := planRepo.Get(context.Background(), &malak.FetchPlanOptions{ + plan1FromDB, err := planRepo.Get(t.Context(), &malak.FetchPlanOptions{ Reference: plan.Reference, }) require.NoError(t, err) diff --git a/internal/datastore/postgres/postgres_test.go b/internal/datastore/postgres/postgres_test.go index 6ad3766f..692c6d24 100644 --- a/internal/datastore/postgres/postgres_test.go +++ b/internal/datastore/postgres/postgres_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "database/sql" "fmt" "os" @@ -106,14 +105,14 @@ func setupDatabase(t *testing.T) (*bun.DB, func()) { } dbContainer, err := testcontainers.GenericContainer( - context.Background(), + t.Context(), testcontainers.GenericContainerRequest{ ContainerRequest: containerReq, Started: true, }) require.NoError(t, err) - port, err := dbContainer.MappedPort(context.Background(), "5432") + port, err := dbContainer.MappedPort(t.Context(), "5432") require.NoError(t, err) dsn = fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", "malaktest", "malaktest", @@ -128,7 +127,7 @@ func setupDatabase(t *testing.T) (*bun.DB, func()) { require.NoError(t, err) return db, func() { - err := dbContainer.Terminate(context.Background()) + err := dbContainer.Terminate(t.Context()) require.NoError(t, err) } } diff --git a/internal/datastore/postgres/preferences_test.go b/internal/datastore/postgres/preferences_test.go index e38f56d5..1dd98af4 100644 --- a/internal/datastore/postgres/preferences_test.go +++ b/internal/datastore/postgres/preferences_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "testing" "github.com/ayinke-llc/malak" @@ -25,12 +24,12 @@ func TestPreferences_Get(t *testing.T) { planRepo := NewPlanRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) - plan, err := planRepo.Get(context.Background(), &malak.FetchPlanOptions{ + plan, err := planRepo.Get(t.Context(), &malak.FetchPlanOptions{ Reference: "prod_QmtErtydaJZymT", }) require.NoError(t, err) @@ -40,9 +39,9 @@ func TestPreferences_Get(t *testing.T) { Workspace: malak.NewWorkspace("oops", user, plan, malak.GenerateReference(malak.EntityTypeWorkspace)), } - require.NoError(t, repo.Create(context.Background(), opts)) + require.NoError(t, repo.Create(t.Context(), opts)) - pref, err := prefRepo.Get(context.Background(), opts.Workspace) + pref, err := prefRepo.Get(t.Context(), opts.Workspace) require.NoError(t, err) require.True(t, pref.Communication.EnableMarketing) @@ -56,7 +55,7 @@ func TestPreferences_Get(t *testing.T) { prefRepo := NewPreferenceRepository(client) - _, err := prefRepo.Get(context.Background(), &malak.Workspace{ + _, err := prefRepo.Get(t.Context(), &malak.Workspace{ ID: uuid.New(), }) require.Error(t, err) @@ -77,12 +76,12 @@ func TestPreferences_Update(t *testing.T) { planRepo := NewPlanRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) - plan, err := planRepo.Get(context.Background(), &malak.FetchPlanOptions{ + plan, err := planRepo.Get(t.Context(), &malak.FetchPlanOptions{ Reference: "prod_QmtErtydaJZymT", }) require.NoError(t, err) @@ -92,21 +91,18 @@ func TestPreferences_Update(t *testing.T) { Workspace: malak.NewWorkspace("oops", user, plan, malak.GenerateReference(malak.EntityTypeWorkspace)), } - require.NoError(t, repo.Create(context.Background(), opts)) + require.NoError(t, repo.Create(t.Context(), opts)) - pref, err := prefRepo.Get(context.Background(), opts.Workspace) + pref, err := prefRepo.Get(t.Context(), opts.Workspace) require.NoError(t, err) require.True(t, pref.Communication.EnableMarketing) require.True(t, pref.Communication.EnableProductUpdates) - ////////// - // Update the pref now - pref.Communication.EnableMarketing = false - require.NoError(t, prefRepo.Update(context.Background(), pref)) + require.NoError(t, prefRepo.Update(t.Context(), pref)) - newPref, err := prefRepo.Get(context.Background(), opts.Workspace) + newPref, err := prefRepo.Get(t.Context(), opts.Workspace) require.NoError(t, err) require.False(t, newPref.Communication.EnableMarketing) diff --git a/internal/datastore/postgres/share_test.go b/internal/datastore/postgres/share_test.go index abe684e1..cd25c482 100644 --- a/internal/datastore/postgres/share_test.go +++ b/internal/datastore/postgres/share_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "testing" "github.com/ayinke-llc/malak" @@ -15,14 +14,14 @@ func TestShare_All(t *testing.T) { shareRepo := NewShareRepository(client) contactRepo := NewContactRepository(client) - contact, err := contactRepo.Get(context.Background(), malak.FetchContactOptions{ + contact, err := contactRepo.Get(t.Context(), malak.FetchContactOptions{ Reference: "contact_kCoC286IR", // contacts.yml WorkspaceID: workspaceID, }) require.NoError(t, err) require.NotNil(t, contact) - sharedContacts, err := shareRepo.All(context.Background(), contact) + sharedContacts, err := shareRepo.All(t.Context(), contact) require.NoError(t, err) require.Len(t, sharedContacts, 0) } diff --git a/internal/datastore/postgres/testdata/fixtures/dashboards.yml b/internal/datastore/postgres/testdata/fixtures/dashboards.yml new file mode 100644 index 00000000..491e54d5 --- /dev/null +++ b/internal/datastore/postgres/testdata/fixtures/dashboards.yml @@ -0,0 +1,19 @@ +--- +- id: 1c8ba03b-80a5-4732-9c29-1f9d168dc02b + reference: dashboard_Rc6YN_1UhT + description: this tracks metrics our next investors round want to see + chart_count: 0 + created_at: "2025-02-20 11:42:41.544979+00" + updated_at: "2025-02-20 11:42:41.544979+00" + deleted_at: + title: series A dashbaord + workspace_id: a4ae79a2-9b76-40d7-b5a1-661e60a02cb0 +- id: 99fc9049-609b-4b6a-ac25-ac0aeb0794de + reference: dashboard_ZsmXYWX2_f + description: this dashboard is for existing investors + chart_count: 0 + created_at: "2025-02-20 11:42:55.290297+00" + updated_at: "2025-02-20 11:42:55.290297+00" + deleted_at: + title: current investors chart + workspace_id: a4ae79a2-9b76-40d7-b5a1-661e60a02cb0 diff --git a/internal/datastore/postgres/update_test.go b/internal/datastore/postgres/update_test.go index c811d7da..df9d415e 100644 --- a/internal/datastore/postgres/update_test.go +++ b/internal/datastore/postgres/update_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "testing" "time" @@ -19,16 +18,16 @@ func TestUpdates_Delete(t *testing.T) { id := uuid.MustParse("0902ef67-903e-47b8-8f9d-111a9e0ca0c7") // workspaces.yml testdata - updateByID, err := updatesRepo.Get(context.Background(), malak.FetchUpdateOptions{ + updateByID, err := updatesRepo.Get(t.Context(), malak.FetchUpdateOptions{ ID: id, Reference: "update_NCox_gRNg", WorkspaceID: uuid.MustParse("c12da796-9362-4c70-b2cb-fc8a1eba2526"), }) require.NoError(t, err) - require.NoError(t, updatesRepo.Delete(context.Background(), updateByID)) + require.NoError(t, updatesRepo.Delete(t.Context(), updateByID)) - _, err = updatesRepo.Get(context.Background(), malak.FetchUpdateOptions{ + _, err = updatesRepo.Get(t.Context(), malak.FetchUpdateOptions{ ID: id, Reference: "update_NCox_gRNg", WorkspaceID: uuid.MustParse("c12da796-9362-4c70-b2cb-fc8a1eba2526"), @@ -44,7 +43,7 @@ func TestUpdates_Update(t *testing.T) { updatesRepo := NewUpdatesRepository(client) - update, err := updatesRepo.Get(context.Background(), malak.FetchUpdateOptions{ + update, err := updatesRepo.Get(t.Context(), malak.FetchUpdateOptions{ Reference: "update_O-54dq6IR", WorkspaceID: uuid.MustParse("c12da796-9362-4c70-b2cb-fc8a1eba2526"), }) @@ -61,9 +60,9 @@ func TestUpdates_Update(t *testing.T) { update.Content = updatedContent - require.NoError(t, updatesRepo.Update(context.Background(), update)) + require.NoError(t, updatesRepo.Update(t.Context(), update)) - updatedItem, err := updatesRepo.Get(context.Background(), malak.FetchUpdateOptions{ + updatedItem, err := updatesRepo.Get(t.Context(), malak.FetchUpdateOptions{ Reference: "update_O-54dq6IR", WorkspaceID: uuid.MustParse("c12da796-9362-4c70-b2cb-fc8a1eba2526"), }) @@ -79,11 +78,11 @@ func TestUpdates_GetByID(t *testing.T) { updatesRepo := NewUpdatesRepository(client) - _, err := updatesRepo.GetByID(context.Background(), + _, err := updatesRepo.GetByID(t.Context(), uuid.MustParse("07b0c648-12fd-44fc-a280-946de2700e65")) require.NoError(t, err) - _, err = updatesRepo.GetByID(context.Background(), uuid.New()) + _, err = updatesRepo.GetByID(t.Context(), uuid.New()) require.Error(t, err) require.Equal(t, err, malak.ErrUpdateNotFound) } @@ -95,20 +94,20 @@ func TestUpdates_Get(t *testing.T) { updatesRepo := NewUpdatesRepository(client) - _, err := updatesRepo.Get(context.Background(), malak.FetchUpdateOptions{ + _, err := updatesRepo.Get(t.Context(), malak.FetchUpdateOptions{ Reference: "update_O-54dq6IR", WorkspaceID: uuid.MustParse("c12da796-9362-4c70-b2cb-fc8a1eba2526"), }) require.NoError(t, err) - updateByID, err := updatesRepo.Get(context.Background(), malak.FetchUpdateOptions{ + updateByID, err := updatesRepo.Get(t.Context(), malak.FetchUpdateOptions{ ID: uuid.MustParse("07b0c648-12fd-44fc-a280-946de2700e65"), Reference: "update_O-54dq6IR", WorkspaceID: uuid.MustParse("c12da796-9362-4c70-b2cb-fc8a1eba2526"), }) require.NoError(t, err) - update, err := updatesRepo.Get(context.Background(), malak.FetchUpdateOptions{ + update, err := updatesRepo.Get(t.Context(), malak.FetchUpdateOptions{ Status: malak.UpdateStatusDraft, ID: uuid.MustParse("07b0c648-12fd-44fc-a280-946de2700e65"), Reference: "update_O-54dq6IR", @@ -129,13 +128,13 @@ func TestUpdates_StatUpdate(t *testing.T) { workspaceRepo := NewWorkspaceRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) @@ -148,18 +147,18 @@ func TestUpdates_StatUpdate(t *testing.T) { Reference: "update_ifjfkjfo", } - err = updatesRepo.Create(context.Background(), update) + err = updatesRepo.Create(t.Context(), update) require.NoError(t, err) - stat, err := updatesRepo.Stat(context.Background(), update) + stat, err := updatesRepo.Stat(t.Context(), update) require.NoError(t, err) require.Equal(t, int64(0), stat.TotalOpens) stat.TotalOpens = 10 - require.NoError(t, updatesRepo.UpdateStat(context.Background(), stat, nil)) + require.NoError(t, updatesRepo.UpdateStat(t.Context(), stat, nil)) - newStat, err := updatesRepo.Stat(context.Background(), update) + newStat, err := updatesRepo.Stat(t.Context(), update) require.NoError(t, err) require.Equal(t, int64(10), newStat.TotalOpens) @@ -175,13 +174,13 @@ func TestUpdates_Stat(t *testing.T) { workspaceRepo := NewWorkspaceRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) @@ -194,10 +193,10 @@ func TestUpdates_Stat(t *testing.T) { Reference: "update_ifjfkjfo", } - err = updatesRepo.Create(context.Background(), update) + err = updatesRepo.Create(t.Context(), update) require.NoError(t, err) - _, err = updatesRepo.Stat(context.Background(), update) + _, err = updatesRepo.Stat(t.Context(), update) require.NoError(t, err) } @@ -211,18 +210,18 @@ func TestUpdates_Create(t *testing.T) { workspaceRepo := NewWorkspaceRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) - err = updatesRepo.Create(context.Background(), &malak.Update{ + err = updatesRepo.Create(t.Context(), &malak.Update{ WorkspaceID: workspace.ID, Status: malak.UpdateStatusDraft, CreatedBy: user.ID, @@ -242,19 +241,19 @@ func TestUpdates_List(t *testing.T) { workspaceRepo := NewWorkspaceRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) require.NotNil(t, user) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) - updates, total, err := updatesRepo.List(context.Background(), malak.ListUpdateOptions{ + updates, total, err := updatesRepo.List(t.Context(), malak.ListUpdateOptions{ WorkspaceID: workspace.ID, Status: malak.ListUpdateFilterStatusAll, }) @@ -263,7 +262,7 @@ func TestUpdates_List(t *testing.T) { require.Len(t, updates, 0) require.Equal(t, int64(0), total) - err = updatesRepo.Create(context.Background(), &malak.Update{ + err = updatesRepo.Create(t.Context(), &malak.Update{ WorkspaceID: workspace.ID, Status: malak.UpdateStatusDraft, CreatedBy: user.ID, @@ -272,7 +271,7 @@ func TestUpdates_List(t *testing.T) { }) require.NoError(t, err) - updates, total, err = updatesRepo.List(context.Background(), malak.ListUpdateOptions{ + updates, total, err = updatesRepo.List(t.Context(), malak.ListUpdateOptions{ WorkspaceID: workspace.ID, Status: malak.ListUpdateFilterStatusAll, }) @@ -292,13 +291,13 @@ func TestUpdates_TogglePinned(t *testing.T) { workspaceRepo := NewWorkspaceRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) @@ -307,7 +306,7 @@ func TestUpdates_TogglePinned(t *testing.T) { // add 3 pinnned updates for i := 0; i <= 3; i++ { - err = updatesRepo.Create(context.Background(), &malak.Update{ + err = updatesRepo.Create(t.Context(), &malak.Update{ WorkspaceID: workspace.ID, Status: malak.UpdateStatusDraft, CreatedBy: user.ID, @@ -328,11 +327,11 @@ func TestUpdates_TogglePinned(t *testing.T) { Reference: ref, } - err = updatesRepo.Create(context.Background(), update) + err = updatesRepo.Create(t.Context(), update) require.NoError(t, err) // cannot add a 4th pinned item - err = updatesRepo.TogglePinned(context.Background(), update) + err = updatesRepo.TogglePinned(t.Context(), update) require.Error(t, err) require.Equal(t, malak.ErrPinnedUpdateCapacityExceeded, err) } @@ -347,13 +346,13 @@ func TestUpdates_SendUpdate(t *testing.T) { workspaceRepo := NewWorkspaceRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) @@ -369,10 +368,10 @@ func TestUpdates_SendUpdate(t *testing.T) { IsPinned: true, } - err = updatesRepo.Create(context.Background(), update) + err = updatesRepo.Create(t.Context(), update) require.NoError(t, err) - err = updatesRepo.SendUpdate(context.Background(), &malak.CreateUpdateOptions{ + err = updatesRepo.SendUpdate(t.Context(), &malak.CreateUpdateOptions{ Reference: func(et malak.EntityType) string { return string(refGenerator.Generate(et)) }, @@ -405,20 +404,20 @@ func TestUpdates_ListPinned(t *testing.T) { workspaceRepo := NewWorkspaceRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) require.NotNil(t, user) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) for range []int{0, 1, 2, 3, 4, 5} { - err = updatesRepo.Create(context.Background(), &malak.Update{ + err = updatesRepo.Create(t.Context(), &malak.Update{ WorkspaceID: workspace.ID, Status: malak.UpdateStatusDraft, CreatedBy: user.ID, @@ -428,7 +427,7 @@ func TestUpdates_ListPinned(t *testing.T) { require.NoError(t, err) } - updates, err := updatesRepo.ListPinned(context.Background(), workspace.ID) + updates, err := updatesRepo.ListPinned(t.Context(), workspace.ID) require.NoError(t, err) require.Len(t, updates, 0) }) @@ -443,14 +442,14 @@ func TestUpdates_ListPinned(t *testing.T) { workspaceRepo := NewWorkspaceRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) require.NotNil(t, user) // from workspaces.yml migration - workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) @@ -464,10 +463,10 @@ func TestUpdates_ListPinned(t *testing.T) { Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeUpdate), } - err = updatesRepo.Create(context.Background(), update) + err = updatesRepo.Create(t.Context(), update) require.NoError(t, err) - require.NoError(t, updatesRepo.TogglePinned(context.Background(), update)) + require.NoError(t, updatesRepo.TogglePinned(t.Context(), update)) } update := &malak.Update{ @@ -478,13 +477,13 @@ func TestUpdates_ListPinned(t *testing.T) { Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeUpdate), } - err = updatesRepo.Create(context.Background(), update) + err = updatesRepo.Create(t.Context(), update) require.NoError(t, err) // max 5 have been added as pinned items - require.Error(t, updatesRepo.TogglePinned(context.Background(), update)) + require.Error(t, updatesRepo.TogglePinned(t.Context(), update)) - updates, err := updatesRepo.ListPinned(context.Background(), workspace.ID) + updates, err := updatesRepo.ListPinned(t.Context(), workspace.ID) require.NoError(t, err) require.Len(t, updates, malak.MaximumNumberOfPinnedUpdates) }) @@ -497,7 +496,7 @@ func TestUpdates_GetStatByEmailID(t *testing.T) { updatesRepo := NewUpdatesRepository(client) - _, _, err := updatesRepo.GetStatByEmailID(context.Background(), + _, _, err := updatesRepo.GetStatByEmailID(t.Context(), "random", malak.UpdateRecipientLogProviderResend) require.Error(t, err) } diff --git a/internal/datastore/postgres/user_test.go b/internal/datastore/postgres/user_test.go index 832407ef..5ce7efbf 100644 --- a/internal/datastore/postgres/user_test.go +++ b/internal/datastore/postgres/user_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "testing" "github.com/ayinke-llc/malak" @@ -17,7 +16,7 @@ func TestUser_Update(t *testing.T) { userRepo := NewUserRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) @@ -26,10 +25,10 @@ func TestUser_Update(t *testing.T) { newName := "Lebron James" user.FullName = newName - require.NoError(t, userRepo.Update(context.TODO(), user)) + require.NoError(t, userRepo.Update(t.Context(), user)) // fetch the user again and check the name - fetchUser, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + fetchUser, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) @@ -61,7 +60,7 @@ func TestUser_Create(t *testing.T) { for _, v := range tt { t.Run(v.name, func(t *testing.T) { - err := userRepo.Create(context.Background(), &malak.User{ + err := userRepo.Create(t.Context(), &malak.User{ Email: malak.Email(v.email), FullName: "Lanre", }) @@ -100,7 +99,7 @@ func TestUser_GetUserID(t *testing.T) { for _, v := range tt { t.Run(v.name, func(t *testing.T) { - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ ID: uuid.MustParse(v.id), }) if v.hasError { @@ -139,7 +138,7 @@ func TestUser_Get(t *testing.T) { for _, v := range tt { t.Run(v.name, func(t *testing.T) { - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: malak.Email(v.email), }) if v.hasError { diff --git a/internal/datastore/postgres/workspace_test.go b/internal/datastore/postgres/workspace_test.go index 07d07545..74351be4 100644 --- a/internal/datastore/postgres/workspace_test.go +++ b/internal/datastore/postgres/workspace_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "testing" "github.com/ayinke-llc/malak" @@ -21,17 +20,17 @@ func TestWorkspace_Create(t *testing.T) { planRepo := NewPlanRepository(client) // user from the fixtures - user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + user, err := userRepo.Get(t.Context(), &malak.FindUserOptions{ Email: "lanre@test.com", }) require.NoError(t, err) - plan, err := planRepo.Get(context.Background(), &malak.FetchPlanOptions{ + plan, err := planRepo.Get(t.Context(), &malak.FetchPlanOptions{ Reference: "prod_QmtErtydaJZymT", }) require.NoError(t, err) - require.NoError(t, repo.Create(context.Background(), &malak.CreateWorkspaceOptions{ + require.NoError(t, repo.Create(t.Context(), &malak.CreateWorkspaceOptions{ User: user, Workspace: malak.NewWorkspace("oops", user, plan, malak.GenerateReference(malak.EntityTypeWorkspace)), })) @@ -45,7 +44,7 @@ func TestWorkspace_Update(t *testing.T) { repo := NewWorkspaceRepository(client) // from workspaces.yml migration - workspace, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) @@ -54,9 +53,9 @@ func TestWorkspace_Update(t *testing.T) { workspace.Reference = newReference - require.NoError(t, repo.Update(context.TODO(), workspace)) + require.NoError(t, repo.Update(t.Context(), workspace)) - newWorkspace, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + newWorkspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) @@ -72,21 +71,21 @@ func TestWorkspace_Get(t *testing.T) { repo := NewWorkspaceRepository(client) // from workspaces.yml migration - workspace, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) require.Equal(t, workspace.WorkspaceName, "First workspace") - workspaceFromRef, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspaceFromRef, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ Reference: malak.Reference(workspace.Reference), }) require.NoError(t, err) require.Equal(t, workspaceFromRef.WorkspaceName, "First workspace") - _, err = repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + _, err = repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("cb5955cc-be42-4fe9-9155-250f4cc0ecc8"), }) require.Error(t, err) @@ -100,7 +99,7 @@ func TestWorkspace_List(t *testing.T) { repo := NewWorkspaceRepository(client) - workspaces, err := repo.List(context.Background(), &malak.User{ + workspaces, err := repo.List(t.Context(), &malak.User{ ID: uuid.MustParse("1aa6b38e-33d3-499f-bc9d-3090738f29e6"), }) require.NoError(t, err) @@ -115,12 +114,12 @@ func TestWorkspace_MarkActive(t *testing.T) { repo := NewWorkspaceRepository(client) // from workspaces.yml migration - workspace, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) - require.NoError(t, repo.MarkActive(context.Background(), workspace)) + require.NoError(t, repo.MarkActive(t.Context(), workspace)) } func TestWorkspace_MarkInActive(t *testing.T) { @@ -131,10 +130,10 @@ func TestWorkspace_MarkInActive(t *testing.T) { repo := NewWorkspaceRepository(client) // from workspaces.yml migration - workspace, err := repo.Get(context.Background(), &malak.FindWorkspaceOptions{ + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) require.NoError(t, err) - require.NoError(t, repo.MarkInActive(context.Background(), workspace)) + require.NoError(t, repo.MarkInActive(t.Context(), workspace)) } diff --git a/internal/integrations/brex/brex.go b/internal/integrations/brex/brex.go index afebaa38..4bdb76df 100644 --- a/internal/integrations/brex/brex.go +++ b/internal/integrations/brex/brex.go @@ -199,11 +199,13 @@ func (m *brexClient) Ping( InternalName: malak.IntegrationChartInternalNameTypeBrexAccount, UserFacingName: account.Name, ProviderID: account.ID, + ChartType: malak.IntegrationChartTypeBar, }) charts = append(charts, malak.IntegrationChartValues{ InternalName: malak.IntegrationChartInternalNameTypeBrexAccount, UserFacingName: "Transactions count for " + account.Name, + ChartType: malak.IntegrationChartTypeBar, }) } diff --git a/internal/integrations/brex/brex_test.go b/internal/integrations/brex/brex_test.go index c284c095..bdd93662 100644 --- a/internal/integrations/brex/brex_test.go +++ b/internal/integrations/brex/brex_test.go @@ -1,7 +1,6 @@ package brex import ( - "context" "fmt" "net/http" "os" @@ -72,7 +71,7 @@ func TestBrexClient_Ping(t *testing.T) { require.NoError(t, err) defer client.Close() - charts, err := client.Ping(context.Background(), tt.token) + charts, err := client.Ping(t.Context(), tt.token) if tt.wantErr { require.Error(t, err) require.Contains(t, err.Error(), tt.errMessage) @@ -119,7 +118,7 @@ func TestBrexClient_buildRequest(t *testing.T) { require.NoError(t, err) brexClient := client.(*brexClient) - req, span, err := brexClient.buildRequest(context.Background(), tt.token, tt.spanName, tt.endpoint) + req, span, err := brexClient.buildRequest(t.Context(), tt.token, tt.spanName, tt.endpoint) if tt.wantErr { require.Error(t, err) require.Contains(t, err.Error(), tt.errMessage) @@ -188,7 +187,7 @@ func TestBrexClient_Data(t *testing.T) { require.NoError(t, err) defer client.Close() - dataPoints, err := client.Data(context.Background(), tt.token, tt.opts) + dataPoints, err := client.Data(t.Context(), tt.token, tt.opts) if tt.wantErr { require.Error(t, err) require.Contains(t, err.Error(), tt.errMessage) diff --git a/internal/integrations/mercury/mercury.go b/internal/integrations/mercury/mercury.go index 1300b84d..82e06135 100644 --- a/internal/integrations/mercury/mercury.go +++ b/internal/integrations/mercury/mercury.go @@ -130,11 +130,13 @@ func (m *mercuryClient) Ping( InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, UserFacingName: account.Name, ProviderID: account.ID, + ChartType: malak.IntegrationChartTypeBar, }) charts = append(charts, malak.IntegrationChartValues{ InternalName: malak.IntegrationChartInternalNameTypeMercuryAccountTransaction, UserFacingName: "Transactions count for " + account.Name, + ChartType: malak.IntegrationChartTypeBar, }) } diff --git a/internal/integrations/mercury/mercury_test.go b/internal/integrations/mercury/mercury_test.go index b6c035c8..f7ec3a67 100644 --- a/internal/integrations/mercury/mercury_test.go +++ b/internal/integrations/mercury/mercury_test.go @@ -1,7 +1,6 @@ package mercury import ( - "context" "fmt" "net/http" "os" @@ -48,7 +47,7 @@ func TestMercuryClient_buildRequest(t *testing.T) { require.NoError(t, err) mercuryClient := client.(*mercuryClient) - req, span, err := mercuryClient.buildRequest(context.Background(), "test-token", "test.span", "/accounts") + req, span, err := mercuryClient.buildRequest(t.Context(), "test-token", "test.span", "/accounts") require.NoError(t, err) require.NotNil(t, req) require.NotNil(t, span) @@ -71,7 +70,7 @@ func TestMercuryClient_InvalidToken(t *testing.T) { defer client.Close() // Test Ping with invalid token - _, err = client.Ping(context.Background(), "invalid-token") + _, err = client.Ping(t.Context(), "invalid-token") require.Error(t, err) // Test Data with invalid token @@ -81,7 +80,7 @@ func TestMercuryClient_InvalidToken(t *testing.T) { ReferenceGenerator: malak.NewReferenceGenerator(), LastFetchedAt: time.Now(), } - _, err = client.Data(context.Background(), "invalid-token", opts) + _, err = client.Data(t.Context(), "invalid-token", opts) require.Error(t, err) } @@ -103,7 +102,7 @@ func TestMercuryClient_ValidToken(t *testing.T) { defer client.Close() // Test Ping with valid token - _, err = client.Ping(context.Background(), malak.AccessToken(token)) + _, err = client.Ping(t.Context(), malak.AccessToken(token)) require.NoError(t, err) // Test Data with valid token @@ -113,7 +112,7 @@ func TestMercuryClient_ValidToken(t *testing.T) { ReferenceGenerator: malak.NewReferenceGenerator(), LastFetchedAt: time.Now(), } - dataPoints, err := client.Data(context.Background(), malak.AccessToken(token), opts) + dataPoints, err := client.Data(t.Context(), malak.AccessToken(token), opts) if err != nil { t.Skip("MERCURY_API_TOKEN does not have access to any accounts") } diff --git a/internal/pkg/billing/stripe/stripe_test.go b/internal/pkg/billing/stripe/stripe_test.go index 863187ef..14da448c 100644 --- a/internal/pkg/billing/stripe/stripe_test.go +++ b/internal/pkg/billing/stripe/stripe_test.go @@ -1,7 +1,6 @@ package stripe import ( - "context" "os" "testing" @@ -46,17 +45,17 @@ func TestCreate_Integration(t *testing.T) { Name: faker.Name(), } - customerID, err := stripeClient.CreateCustomer(context.Background(), opts) + customerID, err := stripeClient.CreateCustomer(t.Context(), opts) require.NoError(t, err) require.NotEmpty(t, customerID) - billingURL, err := stripeClient.Portal(context.Background(), &billing.CreateBillingPortalOptions{ + billingURL, err := stripeClient.Portal(t.Context(), &billing.CreateBillingPortalOptions{ CustomerID: customerID, }) require.NoError(t, err) require.NotEmpty(t, billingURL) - subscriptionID, err := stripeClient.AddPlanToCustomer(context.Background(), &billing.AddPlanToCustomerOptions{ + subscriptionID, err := stripeClient.AddPlanToCustomer(t.Context(), &billing.AddPlanToCustomerOptions{ Workspace: &malak.Workspace{ ID: uuid.New(), StripeCustomerID: customerID, diff --git a/internal/pkg/cache/rediscache/cache_test.go b/internal/pkg/cache/rediscache/cache_test.go index 73611ce5..b5074f6e 100644 --- a/internal/pkg/cache/rediscache/cache_test.go +++ b/internal/pkg/cache/rediscache/cache_test.go @@ -1,7 +1,6 @@ package rediscache import ( - "context" "fmt" "testing" "time" @@ -14,7 +13,7 @@ import ( ) func setupRedis(t *testing.T) (*redis.Client, func()) { - ctx := context.Background() + ctx := t.Context() redisContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ @@ -58,7 +57,7 @@ func TestAdd(t *testing.T) { c, err := New(redisClient) require.NoError(t, err) - ctx := context.Background() + ctx := t.Context() key := "testKey" payload := []byte("testPayload") ttl := time.Hour @@ -84,7 +83,7 @@ func TestExists(t *testing.T) { c, err := New(redisClient) require.NoError(t, err) - ctx := context.Background() + ctx := t.Context() key := "testKey" payload := []byte("testPayload") ttl := time.Hour @@ -112,7 +111,7 @@ func TestExistsError(t *testing.T) { c, err := New(redisClient) require.NoError(t, err) - ctx := context.Background() + ctx := t.Context() key := "testKey" // Close Redis connection to simulate error diff --git a/internal/pkg/email/smtp/smtp_test.go b/internal/pkg/email/smtp/smtp_test.go index 0acab8e6..197d514d 100644 --- a/internal/pkg/email/smtp/smtp_test.go +++ b/internal/pkg/email/smtp/smtp_test.go @@ -1,7 +1,6 @@ package smtp import ( - "context" "testing" "github.com/ayinke-llc/malak" @@ -25,21 +24,21 @@ func TestSMTP_Send(t *testing.T) { } mailpitContainer, err := testcontainers.GenericContainer( - context.Background(), + t.Context(), testcontainers.GenericContainerRequest{ ContainerRequest: containerReq, Started: true, }) require.NoError(t, err) - port, err := mailpitContainer.MappedPort(context.Background(), "1025") + port, err := mailpitContainer.MappedPort(t.Context(), "1025") require.NoError(t, err) client, err := New(getConfig(port.Int())) require.NoError(t, err) - _, err = client.Send(context.Background(), email.SendOptions{ + _, err = client.Send(t.Context(), email.SendOptions{ HTML: "This is my email in html format", Sender: "yo@lanre.wtf", Recipient: "lanre@ayinke.ventures", diff --git a/internal/secret/aes/aes_test.go b/internal/secret/aes/aes_test.go index e3f1dbbd..58c19a71 100644 --- a/internal/secret/aes/aes_test.go +++ b/internal/secret/aes/aes_test.go @@ -218,10 +218,10 @@ func TestAESClient(t *testing.T) { require.NotNil(t, client) secretClient := client.(*aesClient) - encrypted, err := secretClient.Create(context.TODO(), &secret.CreateSecretOptions{Value: tt.plaintext}) + encrypted, err := secretClient.Create(t.Context(), &secret.CreateSecretOptions{Value: tt.plaintext}) require.NoError(t, err) - decrypted, err := secretClient.Get(context.TODO(), encrypted) + decrypted, err := secretClient.Get(t.Context(), encrypted) require.NoError(t, err) require.Equal(t, tt.plaintext, decrypted) }) @@ -237,11 +237,11 @@ func TestAESClientContextCancellation(t *testing.T) { // First create a valid encrypted value plaintext := "test value" - encrypted, err := client.Create(context.Background(), &secret.CreateSecretOptions{Value: plaintext}) + encrypted, err := client.Create(t.Context(), &secret.CreateSecretOptions{Value: plaintext}) require.NoError(t, err) // Now test with cancelled context - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(t.Context()) cancel() // Cancel context immediately // Test Create with cancelled context diff --git a/internal/secret/infisical/infisical_test.go b/internal/secret/infisical/infisical_test.go index d867b251..d1bb1b18 100644 --- a/internal/secret/infisical/infisical_test.go +++ b/internal/secret/infisical/infisical_test.go @@ -1,7 +1,6 @@ package infisical import ( - "context" "fmt" "testing" "time" @@ -110,7 +109,7 @@ func TestInfisicalClientInitialization(t *testing.T) { func TestInfisicalClient(t *testing.T) { t.Skip("INFISICAL IS SO HARD TO RUN. NEED TO READ DOCS later for api endpoint, it seems they mapped it somehwere else") - ctx := context.Background() + ctx := t.Context() // Create a Docker network //nolint:staticcheck diff --git a/internal/secret/secretsmanager/aws_secretmanager_test.go b/internal/secret/secretsmanager/aws_secretmanager_test.go index 7cfc93d4..3d44c1ea 100644 --- a/internal/secret/secretsmanager/aws_secretmanager_test.go +++ b/internal/secret/secretsmanager/aws_secretmanager_test.go @@ -21,7 +21,7 @@ type testSuite struct { } func setupTest(t *testing.T) *testSuite { - ctx := context.Background() + ctx := t.Context() container, err := localstack.Run(ctx, "localstack/localstack:4.1.0", testcontainers.WithEnv(map[string]string{ diff --git a/internal/secret/vault/vault_test.go b/internal/secret/vault/vault_test.go index f31b0d36..c808ee0e 100644 --- a/internal/secret/vault/vault_test.go +++ b/internal/secret/vault/vault_test.go @@ -1,7 +1,6 @@ package vault import ( - "context" "testing" "github.com/ayinke-llc/malak/config" @@ -13,7 +12,7 @@ import ( ) func TestCreateAndGetSecret(t *testing.T) { - ctx := context.Background() + ctx := t.Context() vaultContainer, err := vault.Run(ctx, "hashicorp/vault:1.18.4", @@ -53,7 +52,7 @@ func TestCreateAndGetSecret(t *testing.T) { } func TestCreateAndGetNonexistentSecret(t *testing.T) { - ctx := context.Background() + ctx := t.Context() vaultContainer, err := vault.Run(ctx, "hashicorp/vault:1.18.4", diff --git a/mocks/dashboard.go b/mocks/dashboard.go new file mode 100644 index 00000000..26490c43 --- /dev/null +++ b/mocks/dashboard.go @@ -0,0 +1,116 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: dashboard.go +// +// Generated by this command: +// +// mockgen -source=dashboard.go -destination=mocks/dashboard.go -package=malak_mocks +// + +// Package malak_mocks is a generated GoMock package. +package malak_mocks + +import ( + context "context" + reflect "reflect" + + malak "github.com/ayinke-llc/malak" + gomock "go.uber.org/mock/gomock" +) + +// MockDashboardRepository is a mock of DashboardRepository interface. +type MockDashboardRepository struct { + ctrl *gomock.Controller + recorder *MockDashboardRepositoryMockRecorder + isgomock struct{} +} + +// MockDashboardRepositoryMockRecorder is the mock recorder for MockDashboardRepository. +type MockDashboardRepositoryMockRecorder struct { + mock *MockDashboardRepository +} + +// NewMockDashboardRepository creates a new mock instance. +func NewMockDashboardRepository(ctrl *gomock.Controller) *MockDashboardRepository { + mock := &MockDashboardRepository{ctrl: ctrl} + mock.recorder = &MockDashboardRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDashboardRepository) EXPECT() *MockDashboardRepositoryMockRecorder { + return m.recorder +} + +// AddChart mocks base method. +func (m *MockDashboardRepository) AddChart(arg0 context.Context, arg1 *malak.DashboardChart) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddChart", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddChart indicates an expected call of AddChart. +func (mr *MockDashboardRepositoryMockRecorder) AddChart(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddChart", reflect.TypeOf((*MockDashboardRepository)(nil).AddChart), arg0, arg1) +} + +// Create mocks base method. +func (m *MockDashboardRepository) Create(arg0 context.Context, arg1 *malak.Dashboard) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockDashboardRepositoryMockRecorder) Create(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockDashboardRepository)(nil).Create), arg0, arg1) +} + +// Get mocks base method. +func (m *MockDashboardRepository) Get(arg0 context.Context, arg1 malak.FetchDashboardOption) (malak.Dashboard, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(malak.Dashboard) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockDashboardRepositoryMockRecorder) Get(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDashboardRepository)(nil).Get), arg0, arg1) +} + +// GetCharts mocks base method. +func (m *MockDashboardRepository) GetCharts(arg0 context.Context, arg1 malak.FetchDashboardChartsOption) ([]malak.DashboardChart, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCharts", arg0, arg1) + ret0, _ := ret[0].([]malak.DashboardChart) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCharts indicates an expected call of GetCharts. +func (mr *MockDashboardRepositoryMockRecorder) GetCharts(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCharts", reflect.TypeOf((*MockDashboardRepository)(nil).GetCharts), arg0, arg1) +} + +// List mocks base method. +func (m *MockDashboardRepository) List(arg0 context.Context, arg1 malak.ListDashboardOptions) ([]malak.Dashboard, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", arg0, arg1) + ret0, _ := ret[0].([]malak.Dashboard) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// List indicates an expected call of List. +func (mr *MockDashboardRepositoryMockRecorder) List(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockDashboardRepository)(nil).List), arg0, arg1) +} diff --git a/mocks/integration.go b/mocks/integration.go index efa0f1df..05792fee 100644 --- a/mocks/integration.go +++ b/mocks/integration.go @@ -14,6 +14,7 @@ import ( reflect "reflect" malak "github.com/ayinke-llc/malak" + uuid "github.com/google/uuid" gomock "go.uber.org/mock/gomock" ) @@ -194,6 +195,21 @@ func (mr *MockIntegrationRepositoryMockRecorder) Get(arg0, arg1 any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIntegrationRepository)(nil).Get), arg0, arg1) } +// GetChart mocks base method. +func (m *MockIntegrationRepository) GetChart(arg0 context.Context, arg1 malak.FetchChartOptions) (malak.IntegrationChart, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChart", arg0, arg1) + ret0, _ := ret[0].(malak.IntegrationChart) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChart indicates an expected call of GetChart. +func (mr *MockIntegrationRepositoryMockRecorder) GetChart(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChart", reflect.TypeOf((*MockIntegrationRepository)(nil).GetChart), arg0, arg1) +} + // List mocks base method. func (m *MockIntegrationRepository) List(arg0 context.Context, arg1 *malak.Workspace) ([]malak.WorkspaceIntegration, error) { m.ctrl.T.Helper() @@ -209,6 +225,21 @@ func (mr *MockIntegrationRepositoryMockRecorder) List(arg0, arg1 any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockIntegrationRepository)(nil).List), arg0, arg1) } +// ListCharts mocks base method. +func (m *MockIntegrationRepository) ListCharts(arg0 context.Context, arg1 uuid.UUID) ([]malak.IntegrationChart, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListCharts", arg0, arg1) + ret0, _ := ret[0].([]malak.IntegrationChart) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListCharts indicates an expected call of ListCharts. +func (mr *MockIntegrationRepositoryMockRecorder) ListCharts(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCharts", reflect.TypeOf((*MockIntegrationRepository)(nil).ListCharts), arg0, arg1) +} + // System mocks base method. func (m *MockIntegrationRepository) System(arg0 context.Context) ([]malak.Integration, error) { m.ctrl.T.Helper() diff --git a/plan.go b/plan.go index d1df48ab..09b6f4da 100644 --- a/plan.go +++ b/plan.go @@ -60,8 +60,9 @@ type PlanMetadata struct { } `json:"integrations,omitempty"` Dashboard struct { - ShareDashboardViaLink bool `json:"share_dashboard_via_link,omitempty"` - EmbedDashboard bool `json:"embed_dashboard,omitempty"` + ShareDashboardViaLink bool `json:"share_dashboard_via_link,omitempty"` + EmbedDashboard bool `json:"embed_dashboard,omitempty"` + MaxChartsPerDashboard Counter `json:"max_charts_per_dashboard,omitempty"` } `json:"dashboard,omitempty"` DataRoom struct { diff --git a/reference.go b/reference.go index 9a3edca9..f4e3b58e 100644 --- a/reference.go +++ b/reference.go @@ -22,7 +22,7 @@ func GenerateReference(e EntityType) string { // recipient_stat,recipient_log, // deck,deck_preference, contact_share,dashboard, // plan,price,integration,workspace_integration, integration_datapoint, -// integration_chart, integration_sync_checkpoint) +// integration_chart, integration_sync_checkpoint,dashboard_chart) type EntityType string type Reference string diff --git a/reference_enum.go b/reference_enum.go index 7cf28dbd..f0b23a42 100644 --- a/reference_enum.go +++ b/reference_enum.go @@ -64,6 +64,8 @@ const ( EntityTypeIntegrationChart EntityType = "integration_chart" // EntityTypeIntegrationSyncCheckpoint is a EntityType of type integration_sync_checkpoint. EntityTypeIntegrationSyncCheckpoint EntityType = "integration_sync_checkpoint" + // EntityTypeDashboardChart is a EntityType of type dashboard_chart. + EntityTypeDashboardChart EntityType = "dashboard_chart" ) var ErrInvalidEntityType = errors.New("not a valid EntityType") @@ -107,6 +109,7 @@ var _EntityTypeValue = map[string]EntityType{ "integration_datapoint": EntityTypeIntegrationDatapoint, "integration_chart": EntityTypeIntegrationChart, "integration_sync_checkpoint": EntityTypeIntegrationSyncCheckpoint, + "dashboard_chart": EntityTypeDashboardChart, } // ParseEntityType attempts to convert a string to a EntityType. diff --git a/server/dashboard.go b/server/dashboard.go new file mode 100644 index 00000000..abbbda87 --- /dev/null +++ b/server/dashboard.go @@ -0,0 +1,370 @@ +package server + +import ( + "context" + "errors" + "net/http" + + "github.com/ayinke-llc/hermes" + "github.com/ayinke-llc/malak" + "github.com/ayinke-llc/malak/config" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/microcosm-cc/bluemonday" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" +) + +type dashboardHandler struct { + cfg config.Config + dashboardRepo malak.DashboardRepository + integrationRepo malak.IntegrationRepository + generator malak.ReferenceGeneratorOperation +} + +type createDashboardRequest struct { + GenericRequest + + Title string `json:"title,omitempty" validate:"required"` + Description string `json:"description,omitempty" validate:"required"` +} + +func (c *createDashboardRequest) Validate() error { + if hermes.IsStringEmpty(c.Description) { + return errors.New("please provide a description") + } + + if len(c.Description) > 500 { + return errors.New("description cannot be more than 500 characters") + } + + if hermes.IsStringEmpty(c.Title) { + return errors.New("please provide the title of the dashboard") + } + + if len(c.Title) > 100 { + return errors.New("title cannot be more than 100 characters") + } + + p := bluemonday.StrictPolicy() + + c.Title = p.Sanitize(c.Title) + c.Description = p.Sanitize(c.Description) + + return nil +} + +// @Summary create a new dashboard +// @Tags dashboards +// @Accept json +// @Produce json +// @Param message body createDashboardRequest true "dashboard request body" +// @Success 200 {object} fetchDashboardResponse +// @Failure 400 {object} APIStatus +// @Failure 401 {object} APIStatus +// @Failure 404 {object} APIStatus +// @Failure 500 {object} APIStatus +// @Router /dashboards [post] +func (d *dashboardHandler) create( + ctx context.Context, + span trace.Span, + logger *zap.Logger, + w http.ResponseWriter, + r *http.Request) (render.Renderer, Status) { + + logger.Debug("creating a new dashboard") + + req := new(createDashboardRequest) + + workspace := getWorkspaceFromContext(ctx) + + if err := render.Bind(r, req); err != nil { + return newAPIStatus(http.StatusBadRequest, "invalid request body"), StatusFailed + } + + if err := req.Validate(); err != nil { + return newAPIStatus(http.StatusBadRequest, err.Error()), StatusFailed + } + + dashboard := &malak.Dashboard{ + Description: req.Description, + Title: req.Title, + Reference: d.generator.Generate(malak.EntityTypeDashboard), + ChartCount: 0, + WorkspaceID: workspace.ID, + } + + if err := d.dashboardRepo.Create(ctx, dashboard); err != nil { + logger.Error("could not create dashboard", zap.Error(err)) + return newAPIStatus(http.StatusInternalServerError, "could not create dashboard"), + StatusFailed + } + + return fetchDashboardResponse{ + APIStatus: newAPIStatus(http.StatusOK, "dashboard was successfully created"), + Dashboard: hermes.DeRef(dashboard), + }, StatusSuccess +} + +// @Summary List dashboards +// @Tags dashboards +// @Accept json +// @Produce json +// @Param page query int false "Page to query data from. Defaults to 1" +// @Param per_page query int false "Number to items to return. Defaults to 10 items" +// @Success 200 {object} listDashboardResponse +// @Failure 400 {object} APIStatus +// @Failure 401 {object} APIStatus +// @Failure 404 {object} APIStatus +// @Failure 500 {object} APIStatus +// @Router /dashboards [get] +func (d *dashboardHandler) list( + ctx context.Context, + span trace.Span, + logger *zap.Logger, + w http.ResponseWriter, + r *http.Request) (render.Renderer, Status) { + + logger.Debug("Listing dashboards") + + workspace := getWorkspaceFromContext(r.Context()) + + opts := malak.ListDashboardOptions{ + Paginator: malak.PaginatorFromRequest(r), + WorkspaceID: workspace.ID, + } + + dashboards, total, err := d.dashboardRepo.List(ctx, opts) + if err != nil { + + logger.Error("could not list dashboards", + zap.Error(err)) + + return newAPIStatus( + http.StatusInternalServerError, + "could not list dashboards"), StatusFailed + } + + return listDashboardResponse{ + APIStatus: newAPIStatus(http.StatusOK, "dashboards fetched"), + Dashboards: dashboards, + Meta: meta{ + Paging: pagingInfo{ + PerPage: opts.Paginator.PerPage, + Page: opts.Paginator.Page, + Total: total, + }, + }, + }, StatusSuccess +} + +// @Summary List charts +// @Tags dashboards +// @Accept json +// @Produce json +// @Success 200 {object} listIntegrationChartsResponse +// @Failure 400 {object} APIStatus +// @Failure 401 {object} APIStatus +// @Failure 404 {object} APIStatus +// @Failure 500 {object} APIStatus +// @Router /dashboards/charts [get] +func (d *dashboardHandler) listAllCharts( + ctx context.Context, + span trace.Span, + logger *zap.Logger, + w http.ResponseWriter, + r *http.Request) (render.Renderer, Status) { + + logger.Debug("Listing all charts") + + workspace := getWorkspaceFromContext(r.Context()) + + charts, err := d.integrationRepo.ListCharts(ctx, workspace.ID) + if err != nil { + + logger.Error("could not list charts", + zap.Error(err)) + + return newAPIStatus( + http.StatusInternalServerError, + "could not list charts"), StatusFailed + } + + return listIntegrationChartsResponse{ + APIStatus: newAPIStatus(http.StatusOK, "dashboards fetched"), + Charts: charts, + }, StatusSuccess +} + +type addChartToDashboardRequest struct { + GenericRequest + + ChartReference malak.Reference `json:"chart_reference,omitempty" validate:"required"` +} + +func (c *addChartToDashboardRequest) Validate() error { + if hermes.IsStringEmpty(c.ChartReference.String()) { + return errors.New("please provide a valid chart reference") + } + + return nil +} + +// @Summary add a chart to a dashboard +// @Tags dashboards +// @Accept json +// @Produce json +// @Param message body addChartToDashboardRequest true "dashboard request chart data" +// @Param reference path string required "dashboard unique reference.. e.g dashboard_" +// @Success 200 {object} APIStatus +// @Failure 400 {object} APIStatus +// @Failure 401 {object} APIStatus +// @Failure 404 {object} APIStatus +// @Failure 500 {object} APIStatus +// @Router /dashboards/{reference}/charts [PUT] +func (d *dashboardHandler) addChart( + ctx context.Context, + span trace.Span, + logger *zap.Logger, + w http.ResponseWriter, + r *http.Request) (render.Renderer, Status) { + + logger.Debug("adding a chart to the dashboard") + + workspace := getWorkspaceFromContext(r.Context()) + + req := new(addChartToDashboardRequest) + + if err := render.Bind(r, req); err != nil { + return newAPIStatus(http.StatusBadRequest, "invalid request body"), StatusFailed + } + + if err := req.Validate(); err != nil { + return newAPIStatus(http.StatusBadRequest, err.Error()), StatusFailed + } + + ref := chi.URLParam(r, "reference") + + if hermes.IsStringEmpty(ref) { + return newAPIStatus(http.StatusBadRequest, "reference required"), StatusFailed + } + + dashboard, err := d.dashboardRepo.Get(ctx, malak.FetchDashboardOption{ + Reference: malak.Reference(ref), + WorkspaceID: workspace.ID, + }) + if err != nil { + logger.Error("could not fetch dashboard", zap.Error(err)) + status := http.StatusInternalServerError + msg := "an error occurred while fetching dashboard" + + if errors.Is(err, malak.ErrDashboardNotFound) { + status = http.StatusNotFound + msg = err.Error() + } + + return newAPIStatus(status, msg), StatusFailed + } + + chart, err := d.integrationRepo.GetChart(ctx, malak.FetchChartOptions{ + WorkspaceID: workspace.ID, + Reference: req.ChartReference, + }) + if err != nil { + var status = http.StatusInternalServerError + var message = "an error occurred while fetching chart" + + logger.Error("could not fetch chart from db", + zap.Error(err)) + + if errors.Is(err, malak.ErrChartNotFound) { + status = http.StatusNotFound + message = err.Error() + } + + return newAPIStatus(status, message), StatusFailed + } + + dashChart := &malak.DashboardChart{ + Reference: d.generator.Generate(malak.EntityTypeDashboardChart), + WorkspaceIntegrationID: chart.WorkspaceIntegrationID, + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + ChartID: chart.ID, + } + + if err := d.dashboardRepo.AddChart(ctx, dashChart); err != nil { + logger.Error("could not add chart to dashboard", zap.Error(err)) + return newAPIStatus(http.StatusInternalServerError, "an error occurred while adding chart to dashboard"), + StatusFailed + } + + return newAPIStatus(http.StatusOK, "chart added to dashboard"), + StatusSuccess +} + +// @Summary fetch dashboard +// @Tags dashboards +// @Accept json +// @Produce json +// @Param reference path string required "dashboard unique reference.. e.g dashboard_" +// @Success 200 {object} listDashboardChartsResponse +// @Failure 400 {object} APIStatus +// @Failure 401 {object} APIStatus +// @Failure 404 {object} APIStatus +// @Failure 500 {object} APIStatus +// @Router /dashboards/{reference} [GET] +func (d *dashboardHandler) fetchDashboard( + ctx context.Context, + span trace.Span, + logger *zap.Logger, + w http.ResponseWriter, + r *http.Request) (render.Renderer, Status) { + + logger.Debug("Fetching dashboard") + + workspace := getWorkspaceFromContext(r.Context()) + + ref := chi.URLParam(r, "reference") + + if hermes.IsStringEmpty(ref) { + return newAPIStatus(http.StatusBadRequest, "reference required"), StatusFailed + } + + dashboard, err := d.dashboardRepo.Get(ctx, malak.FetchDashboardOption{ + Reference: malak.Reference(ref), + WorkspaceID: workspace.ID, + }) + if err != nil { + logger.Error("could not fetch dashboard", zap.Error(err)) + status := http.StatusInternalServerError + msg := "an error occurred while fetching dashboard" + + if errors.Is(err, malak.ErrDashboardNotFound) { + status = http.StatusNotFound + msg = err.Error() + } + + return newAPIStatus(status, msg), StatusFailed + } + + charts, err := d.dashboardRepo.GetCharts(ctx, malak.FetchDashboardChartsOption{ + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + }) + if err != nil { + + logger.Error("could not list dashboard charts", + zap.Error(err)) + + return newAPIStatus( + http.StatusInternalServerError, + "could not list dashboard charts"), StatusFailed + } + + return listDashboardChartsResponse{ + APIStatus: newAPIStatus(http.StatusOK, "dashboards fetched"), + Dashboard: dashboard, + Charts: charts, + }, StatusSuccess +} diff --git a/server/dashboard_test.go b/server/dashboard_test.go new file mode 100644 index 00000000..4a11f1f6 --- /dev/null +++ b/server/dashboard_test.go @@ -0,0 +1,603 @@ +package server + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ayinke-llc/malak" + malak_mocks "github.com/ayinke-llc/malak/mocks" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func generateDashboardCreateRequest() []struct { + name string + mockFn func(dashboard *malak_mocks.MockDashboardRepository) + expectedStatusCode int + req createDashboardRequest +} { + return []struct { + name string + mockFn func(dashboard *malak_mocks.MockDashboardRepository) + expectedStatusCode int + req createDashboardRequest + }{ + { + name: "no title provided", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) {}, + expectedStatusCode: http.StatusBadRequest, + req: createDashboardRequest{ + Description: "Test description", + }, + }, + { + name: "no description provided", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) {}, + expectedStatusCode: http.StatusBadRequest, + req: createDashboardRequest{ + Title: "Test Dashboard", + }, + }, + { + name: "title too long", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) {}, + expectedStatusCode: http.StatusBadRequest, + req: createDashboardRequest{ + Title: string(make([]byte, 101)), // 101 characters + Description: "Test description", + }, + }, + { + name: "description too long", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) {}, + expectedStatusCode: http.StatusBadRequest, + req: createDashboardRequest{ + Title: "Test Dashboard", + Description: string(make([]byte, 501)), // 501 characters + }, + }, + { + name: "error creating dashboard", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().Create(gomock.Any(), gomock.Any()). + Times(1). + Return(errors.New("could not create dashboard")) + }, + expectedStatusCode: http.StatusInternalServerError, + req: createDashboardRequest{ + Title: "Test Dashboard", + Description: "Test description", + }, + }, + { + name: "successfully created dashboard", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().Create(gomock.Any(), gomock.Any()). + Times(1). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + req: createDashboardRequest{ + Title: "Test Dashboard", + Description: "Test description", + }, + }, + } +} + +func TestDashboardHandler_Create(t *testing.T) { + for _, v := range generateDashboardCreateRequest() { + t.Run(v.name, func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + dashboardRepo := malak_mocks.NewMockDashboardRepository(controller) + v.mockFn(dashboardRepo) + + h := &dashboardHandler{ + dashboardRepo: dashboardRepo, + generator: &mockReferenceGenerator{}, + cfg: getConfig(), + } + + var b = bytes.NewBuffer(nil) + err := json.NewEncoder(b).Encode(v.req) + require.NoError(t, err) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", b) + req.Header.Add("Content-Type", "application/json") + + req = req.WithContext(writeUserToCtx(req.Context(), &malak.User{})) + req = req.WithContext(writeWorkspaceToCtx(req.Context(), &malak.Workspace{})) + + WrapMalakHTTPHandler(getLogger(t), + h.create, + getConfig(), "dashboards.create"). + ServeHTTP(rr, req) + + require.Equal(t, v.expectedStatusCode, rr.Code) + verifyMatch(t, rr) + }) + } +} + +func generateDashboardListRequest() []struct { + name string + mockFn func(dashboard *malak_mocks.MockDashboardRepository) + expectedStatusCode int +} { + return []struct { + name string + mockFn func(dashboard *malak_mocks.MockDashboardRepository) + expectedStatusCode int + }{ + { + name: "error listing dashboards", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().List(gomock.Any(), gomock.Any()). + Times(1). + Return(nil, int64(0), errors.New("could not list dashboards")) + }, + expectedStatusCode: http.StatusInternalServerError, + }, + { + name: "successfully listed dashboards", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().List(gomock.Any(), gomock.Any()). + Times(1). + Return([]malak.Dashboard{ + { + ID: workspaceID, + Title: "Test Dashboard", + Description: "Test description", + Reference: "DASH_123", + ChartCount: 0, + WorkspaceID: workspaceID, + }, + }, int64(1), nil) + }, + expectedStatusCode: http.StatusOK, + }, + { + name: "empty dashboards list", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().List(gomock.Any(), gomock.Any()). + Times(1). + Return([]malak.Dashboard{}, int64(0), nil) + }, + expectedStatusCode: http.StatusOK, + }, + } +} + +func TestDashboardHandler_List(t *testing.T) { + for _, v := range generateDashboardListRequest() { + t.Run(v.name, func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + dashboardRepo := malak_mocks.NewMockDashboardRepository(controller) + v.mockFn(dashboardRepo) + + h := &dashboardHandler{ + dashboardRepo: dashboardRepo, + generator: &mockReferenceGenerator{}, + cfg: getConfig(), + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("Content-Type", "application/json") + + req = req.WithContext(writeUserToCtx(req.Context(), &malak.User{})) + req = req.WithContext(writeWorkspaceToCtx(req.Context(), &malak.Workspace{ID: workspaceID})) + + WrapMalakHTTPHandler(getLogger(t), + h.list, + getConfig(), "dashboards.list"). + ServeHTTP(rr, req) + + require.Equal(t, v.expectedStatusCode, rr.Code) + verifyMatch(t, rr) + }) + } +} + +func generateListAllChartsRequest() []struct { + name string + mockFn func(integration *malak_mocks.MockIntegrationRepository) + expectedStatusCode int +} { + return []struct { + name string + mockFn func(integration *malak_mocks.MockIntegrationRepository) + expectedStatusCode int + }{ + { + name: "error listing charts", + mockFn: func(integration *malak_mocks.MockIntegrationRepository) { + integration.EXPECT().ListCharts(gomock.Any(), gomock.Any()). + Times(1). + Return(nil, errors.New("could not list charts")) + }, + expectedStatusCode: http.StatusInternalServerError, + }, + { + name: "successfully listed charts", + mockFn: func(integration *malak_mocks.MockIntegrationRepository) { + integration.EXPECT().ListCharts(gomock.Any(), gomock.Any()). + Times(1). + Return([]malak.IntegrationChart{ + { + ID: workspaceID, + WorkspaceIntegrationID: workspaceID, + WorkspaceID: workspaceID, + Reference: "CHART_123", + UserFacingName: "Test Chart", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + }, + }, nil) + }, + expectedStatusCode: http.StatusOK, + }, + { + name: "empty charts list", + mockFn: func(integration *malak_mocks.MockIntegrationRepository) { + integration.EXPECT().ListCharts(gomock.Any(), gomock.Any()). + Times(1). + Return([]malak.IntegrationChart{}, nil) + }, + expectedStatusCode: http.StatusOK, + }, + } +} + +func TestDashboardHandler_ListAllCharts(t *testing.T) { + for _, v := range generateListAllChartsRequest() { + t.Run(v.name, func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + integrationRepo := malak_mocks.NewMockIntegrationRepository(controller) + v.mockFn(integrationRepo) + + h := &dashboardHandler{ + integrationRepo: integrationRepo, + cfg: getConfig(), + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("Content-Type", "application/json") + + req = req.WithContext(writeUserToCtx(req.Context(), &malak.User{})) + req = req.WithContext(writeWorkspaceToCtx(req.Context(), &malak.Workspace{ID: workspaceID})) + + WrapMalakHTTPHandler(getLogger(t), + h.listAllCharts, + getConfig(), "dashboards.listAllCharts"). + ServeHTTP(rr, req) + + require.Equal(t, v.expectedStatusCode, rr.Code) + verifyMatch(t, rr) + }) + } +} + +func generateAddChartRequest() []struct { + name string + mockFn func(dashboard *malak_mocks.MockDashboardRepository, integration *malak_mocks.MockIntegrationRepository) + expectedStatusCode int + req addChartToDashboardRequest +} { + return []struct { + name string + mockFn func(dashboard *malak_mocks.MockDashboardRepository, integration *malak_mocks.MockIntegrationRepository) + expectedStatusCode int + req addChartToDashboardRequest + }{ + { + name: "no chart reference provided", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository, integration *malak_mocks.MockIntegrationRepository) { + }, + expectedStatusCode: http.StatusBadRequest, + req: addChartToDashboardRequest{}, + }, + { + name: "dashboard not found", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository, integration *malak_mocks.MockIntegrationRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{}, malak.ErrDashboardNotFound) + }, + expectedStatusCode: http.StatusNotFound, + req: addChartToDashboardRequest{ + ChartReference: "CHART_123", + }, + }, + { + name: "error fetching dashboard", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository, integration *malak_mocks.MockIntegrationRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{}, errors.New("error fetching dashboard")) + }, + expectedStatusCode: http.StatusInternalServerError, + req: addChartToDashboardRequest{ + ChartReference: "CHART_123", + }, + }, + { + name: "chart not found", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository, integration *malak_mocks.MockIntegrationRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{ + ID: workspaceID, + Title: "Test Dashboard", + Description: "Test description", + Reference: "DASH_123", + ChartCount: 0, + WorkspaceID: workspaceID, + }, nil) + + integration.EXPECT().GetChart(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.IntegrationChart{}, malak.ErrChartNotFound) + }, + expectedStatusCode: http.StatusNotFound, + req: addChartToDashboardRequest{ + ChartReference: "CHART_123", + }, + }, + { + name: "error fetching chart", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository, integration *malak_mocks.MockIntegrationRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{ + ID: workspaceID, + Title: "Test Dashboard", + Description: "Test description", + Reference: "DASH_123", + ChartCount: 0, + WorkspaceID: workspaceID, + }, nil) + + integration.EXPECT().GetChart(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.IntegrationChart{}, errors.New("error fetching chart")) + }, + expectedStatusCode: http.StatusInternalServerError, + req: addChartToDashboardRequest{ + ChartReference: "CHART_123", + }, + }, + { + name: "error adding chart to dashboard", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository, integration *malak_mocks.MockIntegrationRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{ + ID: workspaceID, + Title: "Test Dashboard", + Description: "Test description", + Reference: "DASH_123", + ChartCount: 0, + WorkspaceID: workspaceID, + }, nil) + + integration.EXPECT().GetChart(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.IntegrationChart{ + ID: workspaceID, + WorkspaceIntegrationID: workspaceID, + Reference: "CHART_123", + WorkspaceID: workspaceID, + }, nil) + + dashboard.EXPECT().AddChart(gomock.Any(), gomock.Any()). + Times(1). + Return(errors.New("error adding chart")) + }, + expectedStatusCode: http.StatusInternalServerError, + req: addChartToDashboardRequest{ + ChartReference: "CHART_123", + }, + }, + { + name: "successfully added chart to dashboard", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository, integration *malak_mocks.MockIntegrationRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{ + ID: workspaceID, + Title: "Test Dashboard", + Description: "Test description", + Reference: "DASH_123", + ChartCount: 0, + WorkspaceID: workspaceID, + }, nil) + + integration.EXPECT().GetChart(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.IntegrationChart{ + ID: workspaceID, + WorkspaceIntegrationID: workspaceID, + Reference: "CHART_123", + WorkspaceID: workspaceID, + }, nil) + + dashboard.EXPECT().AddChart(gomock.Any(), gomock.Any()). + Times(1). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + req: addChartToDashboardRequest{ + ChartReference: "CHART_123", + }, + }, + } +} + +func TestDashboardHandler_AddChart(t *testing.T) { + for _, v := range generateAddChartRequest() { + t.Run(v.name, func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + dashboardRepo := malak_mocks.NewMockDashboardRepository(controller) + integrationRepo := malak_mocks.NewMockIntegrationRepository(controller) + v.mockFn(dashboardRepo, integrationRepo) + + h := &dashboardHandler{ + dashboardRepo: dashboardRepo, + integrationRepo: integrationRepo, + generator: &mockReferenceGenerator{}, + cfg: getConfig(), + } + + var b = bytes.NewBuffer(nil) + err := json.NewEncoder(b).Encode(v.req) + require.NoError(t, err) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/dashboards/DASH_123/charts", b) + req.Header.Add("Content-Type", "application/json") + + req = req.WithContext(writeUserToCtx(req.Context(), &malak.User{})) + req = req.WithContext(writeWorkspaceToCtx(req.Context(), &malak.Workspace{ID: workspaceID})) + + router := chi.NewRouter() + router.Put("/dashboards/{reference}/charts", WrapMalakHTTPHandler(getLogger(t), + h.addChart, + getConfig(), "dashboards.add_chart").ServeHTTP) + + router.ServeHTTP(rr, req) + + require.Equal(t, v.expectedStatusCode, rr.Code) + verifyMatch(t, rr) + }) + } +} + +func generateFetchDashboardRequest() []struct { + name string + mockFn func(dashboard *malak_mocks.MockDashboardRepository) + expectedStatusCode int +} { + return []struct { + name string + mockFn func(dashboard *malak_mocks.MockDashboardRepository) + expectedStatusCode int + }{ + { + name: "dashboard not found", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{}, malak.ErrDashboardNotFound) + }, + expectedStatusCode: http.StatusNotFound, + }, + { + name: "error fetching dashboard", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{}, errors.New("error fetching dashboard")) + }, + expectedStatusCode: http.StatusInternalServerError, + }, + { + name: "error fetching dashboard charts", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{ + ID: workspaceID, + Title: "Test Dashboard", + Description: "Test description", + Reference: "DASH_123", + ChartCount: 0, + WorkspaceID: workspaceID, + }, nil) + + dashboard.EXPECT().GetCharts(gomock.Any(), gomock.Any()). + Times(1). + Return(nil, errors.New("error fetching charts")) + }, + expectedStatusCode: http.StatusInternalServerError, + }, + { + name: "successfully fetched dashboard and charts", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{ + ID: workspaceID, + Title: "Test Dashboard", + Description: "Test description", + Reference: "DASH_123", + ChartCount: 1, + WorkspaceID: workspaceID, + }, nil) + + dashboard.EXPECT().GetCharts(gomock.Any(), gomock.Any()). + Times(1). + Return([]malak.DashboardChart{ + { + ID: workspaceID, + Reference: "DASHCHART_123", + WorkspaceIntegrationID: workspaceID, + WorkspaceID: workspaceID, + DashboardID: workspaceID, + ChartID: workspaceID, + }, + }, nil) + }, + expectedStatusCode: http.StatusOK, + }, + } +} + +func TestDashboardHandler_FetchDashboard(t *testing.T) { + for _, v := range generateFetchDashboardRequest() { + t.Run(v.name, func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + dashboardRepo := malak_mocks.NewMockDashboardRepository(controller) + v.mockFn(dashboardRepo) + + h := &dashboardHandler{ + dashboardRepo: dashboardRepo, + generator: &mockReferenceGenerator{}, + cfg: getConfig(), + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/dashboards/DASH_123", nil) + req.Header.Add("Content-Type", "application/json") + + req = req.WithContext(writeUserToCtx(req.Context(), &malak.User{})) + req = req.WithContext(writeWorkspaceToCtx(req.Context(), &malak.Workspace{ID: workspaceID})) + + router := chi.NewRouter() + router.Get("/dashboards/{reference}", WrapMalakHTTPHandler(getLogger(t), + h.fetchDashboard, + getConfig(), "dashboards.fetch").ServeHTTP) + + router.ServeHTTP(rr, req) + + require.Equal(t, v.expectedStatusCode, rr.Code) + verifyMatch(t, rr) + }) + } +} diff --git a/server/http.go b/server/http.go index e89c7665..03ef107d 100644 --- a/server/http.go +++ b/server/http.go @@ -31,6 +31,7 @@ func New(logger *zap.Logger, db *bun.DB, jwtTokenManager jwttoken.JWTokenManager, googleAuthProvider socialauth.SocialAuthProvider, + dashboardRepo malak.DashboardRepository, userRepo malak.UserRepository, workspaceRepo malak.WorkspaceRepository, planRepo malak.PlanRepository, @@ -56,6 +57,7 @@ func New(logger *zap.Logger, srv := &http.Server{ Handler: buildRoutes(logger, db, cfg, jwtTokenManager, + dashboardRepo, userRepo, workspaceRepo, planRepo, contactRepo, updateRepo, contactListRepo, deckRepo, shareRepo, preferenceRepo, integrationRepo, @@ -93,6 +95,7 @@ func buildRoutes( _ *bun.DB, cfg config.Config, jwtTokenManager jwttoken.JWTokenManager, + dashboardRepo malak.DashboardRepository, userRepo malak.UserRepository, workspaceRepo malak.WorkspaceRepository, planRepo malak.PlanRepository, @@ -197,6 +200,13 @@ func buildRoutes( cfg: cfg, } + dashHandler := &dashboardHandler{ + cfg: cfg, + dashboardRepo: dashboardRepo, + generator: referenceGenerator, + integrationRepo: integrationRepo, + } + router.Use(middleware.RequestID) router.Use(writeRequestIDHeader) router.Use( @@ -371,6 +381,26 @@ func buildRoutes( }) + r.Route("/dashboards", func(r chi.Router) { + r.Use(requireAuthentication(logger, jwtTokenManager, cfg, userRepo, workspaceRepo)) + r.Use(requireWorkspaceValidSubscription(cfg)) + + r.Post("/", + WrapMalakHTTPHandler(logger, dashHandler.create, cfg, "dashboards.create")) + + r.Get("/", + WrapMalakHTTPHandler(logger, dashHandler.list, cfg, "dashboards.list")) + + r.Get("/charts", + WrapMalakHTTPHandler(logger, dashHandler.listAllCharts, cfg, "dashboards.list.charts")) + + r.Get("/{reference}", + WrapMalakHTTPHandler(logger, dashHandler.fetchDashboard, cfg, "dashboards.fetch")) + + r.Put("/{reference}/charts", + WrapMalakHTTPHandler(logger, dashHandler.addChart, cfg, "dashboards.charts.add")) + }) + r.Route("/uploads", func(r chi.Router) { r.Use(requireAuthentication(logger, jwtTokenManager, cfg, userRepo, workspaceRepo)) diff --git a/server/http_test.go b/server/http_test.go index 0b436b9d..ce69b0f1 100644 --- a/server/http_test.go +++ b/server/http_test.go @@ -32,6 +32,7 @@ func TestServer_New(t *testing.T) { srv, closeFn := New(getLogger(t), cfg, &bun.DB{}, jwttoken.New(cfg), socialauth.NewGoogle(cfg), + malak_mocks.NewMockDashboardRepository(controller), malak_mocks.NewMockUserRepository(controller), malak_mocks.NewMockWorkspaceRepository(controller), malak_mocks.NewMockPlanRepository(controller), @@ -77,6 +78,7 @@ func TestServer_New(t *testing.T) { srv, closeFn := New(getLogger(t), cfg, &bun.DB{}, jwttoken.New(cfg), socialauth.NewGoogle(cfg), + malak_mocks.NewMockDashboardRepository(controller), malak_mocks.NewMockUserRepository(controller), malak_mocks.NewMockWorkspaceRepository(controller), malak_mocks.NewMockPlanRepository(controller), @@ -138,6 +140,7 @@ func TestNew(t *testing.T) { db := &bun.DB{} srv, closeFn := New(logger, cfg, db, jwttoken.New(cfg), socialauth.NewGoogle(cfg), + malak_mocks.NewMockDashboardRepository(controller), userRepo, workspaceRepo, planRepo, contactRepo, updateRepo, contactListRepo, deckRepo, contactShareRepo, preferenceRepo, integrationRepo, &httplimit.Middleware{}, &gulter.Gulter{}, queueRepo, cacheRepo, @@ -175,6 +178,7 @@ func TestNewWithInvalidConfig(t *testing.T) { db := &bun.DB{} srv, closeFn := New(logger, cfg, db, jwttoken.New(cfg), socialauth.NewGoogle(cfg), + malak_mocks.NewMockDashboardRepository(controller), userRepo, workspaceRepo, planRepo, contactRepo, updateRepo, contactListRepo, deckRepo, contactShareRepo, preferenceRepo, integrationRepo, &httplimit.Middleware{}, &gulter.Gulter{}, queueRepo, cacheRepo, diff --git a/server/middleware_test.go b/server/middleware_test.go index 9609ed34..51689531 100644 --- a/server/middleware_test.go +++ b/server/middleware_test.go @@ -1,7 +1,6 @@ package server import ( - "context" "encoding/json" "errors" "net/http" @@ -185,7 +184,7 @@ func TestHTTPThrottleKeyFunc(t *testing.T) { func TestContextHelpers(t *testing.T) { t.Run("user context", func(t *testing.T) { - ctx := context.Background() + ctx := t.Context() user := &malak.User{ID: uuid.New()} // Test writing and reading user @@ -195,7 +194,7 @@ func TestContextHelpers(t *testing.T) { }) t.Run("workspace context", func(t *testing.T) { - ctx := context.Background() + ctx := t.Context() workspace := &malak.Workspace{ID: uuid.New()} // Test writing and reading workspace diff --git a/server/response.go b/server/response.go index d0770a2a..b1242976 100644 --- a/server/response.go +++ b/server/response.go @@ -77,6 +77,23 @@ type listUpdateResponse struct { APIStatus } +type listIntegrationChartsResponse struct { + Charts []malak.IntegrationChart `json:"charts,omitempty" validate:"required"` + APIStatus +} + +type listDashboardChartsResponse struct { + Charts []malak.DashboardChart `json:"charts,omitempty" validate:"required"` + Dashboard malak.Dashboard `json:"dashboard,omitempty" validate:"required"` + APIStatus +} + +type listDashboardResponse struct { + Meta meta `json:"meta,omitempty" validate:"required"` + Dashboards []malak.Dashboard `json:"dashboards,omitempty" validate:"required"` + APIStatus +} + type uploadImageResponse struct { URL string `json:"url,omitempty" validate:"required"` APIStatus @@ -141,3 +158,8 @@ type fetchBillingPortalResponse struct { Link string `json:"link,omitempty" validate:"required"` APIStatus } + +type fetchDashboardResponse struct { + Dashboard malak.Dashboard `json:"dashboard,omitempty" validate:"required"` + APIStatus +} diff --git a/server/testdata/TestDashboardHandler_AddChart/chart_not_found.golden b/server/testdata/TestDashboardHandler_AddChart/chart_not_found.golden new file mode 100644 index 00000000..0bd6a132 --- /dev/null +++ b/server/testdata/TestDashboardHandler_AddChart/chart_not_found.golden @@ -0,0 +1 @@ +{"message":"chart not found"} diff --git a/server/testdata/TestDashboardHandler_AddChart/dashboard_not_found.golden b/server/testdata/TestDashboardHandler_AddChart/dashboard_not_found.golden new file mode 100644 index 00000000..b01f1398 --- /dev/null +++ b/server/testdata/TestDashboardHandler_AddChart/dashboard_not_found.golden @@ -0,0 +1 @@ +{"message":"dashboard not found"} diff --git a/server/testdata/TestDashboardHandler_AddChart/error_adding_chart_to_dashboard.golden b/server/testdata/TestDashboardHandler_AddChart/error_adding_chart_to_dashboard.golden new file mode 100644 index 00000000..b96f725e --- /dev/null +++ b/server/testdata/TestDashboardHandler_AddChart/error_adding_chart_to_dashboard.golden @@ -0,0 +1 @@ +{"message":"an error occurred while adding chart to dashboard"} diff --git a/server/testdata/TestDashboardHandler_AddChart/error_fetching_chart.golden b/server/testdata/TestDashboardHandler_AddChart/error_fetching_chart.golden new file mode 100644 index 00000000..03c0463b --- /dev/null +++ b/server/testdata/TestDashboardHandler_AddChart/error_fetching_chart.golden @@ -0,0 +1 @@ +{"message":"an error occurred while fetching chart"} diff --git a/server/testdata/TestDashboardHandler_AddChart/error_fetching_dashboard.golden b/server/testdata/TestDashboardHandler_AddChart/error_fetching_dashboard.golden new file mode 100644 index 00000000..be76c74b --- /dev/null +++ b/server/testdata/TestDashboardHandler_AddChart/error_fetching_dashboard.golden @@ -0,0 +1 @@ +{"message":"an error occurred while fetching dashboard"} diff --git a/server/testdata/TestDashboardHandler_AddChart/no_chart_reference_provided.golden b/server/testdata/TestDashboardHandler_AddChart/no_chart_reference_provided.golden new file mode 100644 index 00000000..796af1af --- /dev/null +++ b/server/testdata/TestDashboardHandler_AddChart/no_chart_reference_provided.golden @@ -0,0 +1 @@ +{"message":"please provide a valid chart reference"} diff --git a/server/testdata/TestDashboardHandler_AddChart/successfully_added_chart_to_dashboard.golden b/server/testdata/TestDashboardHandler_AddChart/successfully_added_chart_to_dashboard.golden new file mode 100644 index 00000000..3de8d00e --- /dev/null +++ b/server/testdata/TestDashboardHandler_AddChart/successfully_added_chart_to_dashboard.golden @@ -0,0 +1 @@ +{"message":"chart added to dashboard"} diff --git a/server/testdata/TestDashboardHandler_Create/description_too_long.golden b/server/testdata/TestDashboardHandler_Create/description_too_long.golden new file mode 100644 index 00000000..5f09291d --- /dev/null +++ b/server/testdata/TestDashboardHandler_Create/description_too_long.golden @@ -0,0 +1 @@ +{"message":"description cannot be more than 500 characters"} diff --git a/server/testdata/TestDashboardHandler_Create/error_creating_dashboard.golden b/server/testdata/TestDashboardHandler_Create/error_creating_dashboard.golden new file mode 100644 index 00000000..6ec96bc9 --- /dev/null +++ b/server/testdata/TestDashboardHandler_Create/error_creating_dashboard.golden @@ -0,0 +1 @@ +{"message":"could not create dashboard"} diff --git a/server/testdata/TestDashboardHandler_Create/no_description_provided.golden b/server/testdata/TestDashboardHandler_Create/no_description_provided.golden new file mode 100644 index 00000000..6a82d1a1 --- /dev/null +++ b/server/testdata/TestDashboardHandler_Create/no_description_provided.golden @@ -0,0 +1 @@ +{"message":"please provide a description"} diff --git a/server/testdata/TestDashboardHandler_Create/no_title_provided.golden b/server/testdata/TestDashboardHandler_Create/no_title_provided.golden new file mode 100644 index 00000000..e5f55328 --- /dev/null +++ b/server/testdata/TestDashboardHandler_Create/no_title_provided.golden @@ -0,0 +1 @@ +{"message":"please provide the title of the dashboard"} diff --git a/server/testdata/TestDashboardHandler_Create/successfully_created_dashboard.golden b/server/testdata/TestDashboardHandler_Create/successfully_created_dashboard.golden new file mode 100644 index 00000000..bb8d9cc5 --- /dev/null +++ b/server/testdata/TestDashboardHandler_Create/successfully_created_dashboard.golden @@ -0,0 +1 @@ +{"dashboard":{"id":"00000000-0000-0000-0000-000000000000","reference":"dashboard_test_reference","description":"Test description","title":"Test Dashboard","workspace_id":"00000000-0000-0000-0000-000000000000","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"},"message":"dashboard was successfully created"} diff --git a/server/testdata/TestDashboardHandler_Create/title_too_long.golden b/server/testdata/TestDashboardHandler_Create/title_too_long.golden new file mode 100644 index 00000000..c06c7f47 --- /dev/null +++ b/server/testdata/TestDashboardHandler_Create/title_too_long.golden @@ -0,0 +1 @@ +{"message":"title cannot be more than 100 characters"} diff --git a/server/testdata/TestDashboardHandler_FetchDashboard/dashboard_not_found.golden b/server/testdata/TestDashboardHandler_FetchDashboard/dashboard_not_found.golden new file mode 100644 index 00000000..b01f1398 --- /dev/null +++ b/server/testdata/TestDashboardHandler_FetchDashboard/dashboard_not_found.golden @@ -0,0 +1 @@ +{"message":"dashboard not found"} diff --git a/server/testdata/TestDashboardHandler_FetchDashboard/error_fetching_dashboard.golden b/server/testdata/TestDashboardHandler_FetchDashboard/error_fetching_dashboard.golden new file mode 100644 index 00000000..be76c74b --- /dev/null +++ b/server/testdata/TestDashboardHandler_FetchDashboard/error_fetching_dashboard.golden @@ -0,0 +1 @@ +{"message":"an error occurred while fetching dashboard"} diff --git a/server/testdata/TestDashboardHandler_FetchDashboard/error_fetching_dashboard_charts.golden b/server/testdata/TestDashboardHandler_FetchDashboard/error_fetching_dashboard_charts.golden new file mode 100644 index 00000000..550eac00 --- /dev/null +++ b/server/testdata/TestDashboardHandler_FetchDashboard/error_fetching_dashboard_charts.golden @@ -0,0 +1 @@ +{"message":"could not list dashboard charts"} diff --git a/server/testdata/TestDashboardHandler_FetchDashboard/successfully_fetched_dashboard_and_charts.golden b/server/testdata/TestDashboardHandler_FetchDashboard/successfully_fetched_dashboard_and_charts.golden new file mode 100644 index 00000000..71958a13 --- /dev/null +++ b/server/testdata/TestDashboardHandler_FetchDashboard/successfully_fetched_dashboard_and_charts.golden @@ -0,0 +1 @@ +{"charts":[{"id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","workspace_integration_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","reference":"DASHCHART_123","workspace_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","dashboard_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","chart_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"}],"dashboard":{"id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","reference":"DASH_123","description":"Test description","title":"Test Dashboard","workspace_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","chart_count":1,"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"},"message":"dashboards fetched"} diff --git a/server/testdata/TestDashboardHandler_List/empty_dashboards_list.golden b/server/testdata/TestDashboardHandler_List/empty_dashboards_list.golden new file mode 100644 index 00000000..f7f99a99 --- /dev/null +++ b/server/testdata/TestDashboardHandler_List/empty_dashboards_list.golden @@ -0,0 +1 @@ +{"meta":{"paging":{"per_page":8,"page":1}},"message":"dashboards fetched"} diff --git a/server/testdata/TestDashboardHandler_List/error_listing_dashboards.golden b/server/testdata/TestDashboardHandler_List/error_listing_dashboards.golden new file mode 100644 index 00000000..61cf674d --- /dev/null +++ b/server/testdata/TestDashboardHandler_List/error_listing_dashboards.golden @@ -0,0 +1 @@ +{"message":"could not list dashboards"} diff --git a/server/testdata/TestDashboardHandler_List/successfully_listed_dashboards.golden b/server/testdata/TestDashboardHandler_List/successfully_listed_dashboards.golden new file mode 100644 index 00000000..d83b32e4 --- /dev/null +++ b/server/testdata/TestDashboardHandler_List/successfully_listed_dashboards.golden @@ -0,0 +1 @@ +{"meta":{"paging":{"total":1,"per_page":8,"page":1}},"dashboards":[{"id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","reference":"DASH_123","description":"Test description","title":"Test Dashboard","workspace_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"}],"message":"dashboards fetched"} diff --git a/server/testdata/TestDashboardHandler_ListAllCharts/empty_charts_list.golden b/server/testdata/TestDashboardHandler_ListAllCharts/empty_charts_list.golden new file mode 100644 index 00000000..77a1346d --- /dev/null +++ b/server/testdata/TestDashboardHandler_ListAllCharts/empty_charts_list.golden @@ -0,0 +1 @@ +{"message":"dashboards fetched"} diff --git a/server/testdata/TestDashboardHandler_ListAllCharts/error_listing_charts.golden b/server/testdata/TestDashboardHandler_ListAllCharts/error_listing_charts.golden new file mode 100644 index 00000000..58937fbc --- /dev/null +++ b/server/testdata/TestDashboardHandler_ListAllCharts/error_listing_charts.golden @@ -0,0 +1 @@ +{"message":"could not list charts"} diff --git a/server/testdata/TestDashboardHandler_ListAllCharts/successfully_listed_charts.golden b/server/testdata/TestDashboardHandler_ListAllCharts/successfully_listed_charts.golden new file mode 100644 index 00000000..0be0f3da --- /dev/null +++ b/server/testdata/TestDashboardHandler_ListAllCharts/successfully_listed_charts.golden @@ -0,0 +1 @@ +{"charts":[{"id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","workspace_integration_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","workspace_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","reference":"CHART_123","user_facing_name":"Test Chart","internal_name":"mercury_account","metadata":{},"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"}],"message":"dashboards fetched"} diff --git a/swagger/docs.go b/swagger/docs.go index 23847381..35e5671f 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -661,6 +661,286 @@ const docTemplate = `{ } } }, + "/dashboards": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboards" + ], + "summary": "List dashboards", + "parameters": [ + { + "type": "integer", + "description": "Page to query data from. Defaults to 1", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Number to items to return. Defaults to 10 items", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.listDashboardResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboards" + ], + "summary": "create a new dashboard", + "parameters": [ + { + "description": "dashboard request body", + "name": "message", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.createDashboardRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.fetchDashboardResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + } + } + } + }, + "/dashboards/charts": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboards" + ], + "summary": "List charts", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.listIntegrationChartsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + } + } + } + }, + "/dashboards/{reference}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboards" + ], + "summary": "fetch dashboard", + "parameters": [ + { + "type": "string", + "description": "dashboard unique reference.. e.g dashboard_", + "name": "reference", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.listDashboardChartsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + } + } + } + }, + "/dashboards/{reference}/charts": { + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboards" + ], + "summary": "add a chart to a dashboard", + "parameters": [ + { + "description": "dashboard request chart data", + "name": "message", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.addChartToDashboardRequest" + } + }, + { + "type": "string", + "description": "dashboard unique reference.. e.g dashboard_", + "name": "reference", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + } + } + } + }, "/decks": { "get": { "consumes": [ @@ -2767,6 +3047,67 @@ const docTemplate = `{ "type": "string" } }, + "malak.Dashboard": { + "type": "object", + "properties": { + "chart_count": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + } + }, + "malak.DashboardChart": { + "type": "object", + "properties": { + "chart": { + "$ref": "#/definitions/malak.IntegrationChart" + }, + "chart_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "dashboard_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "workspace_integration_id": { + "type": "string" + } + } + }, "malak.Deck": { "type": "object", "properties": { @@ -2884,6 +3225,75 @@ const docTemplate = `{ } } }, + "malak.IntegrationChart": { + "type": "object", + "properties": { + "chart_type": { + "$ref": "#/definitions/malak.IntegrationChartType" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "internal_name": { + "$ref": "#/definitions/malak.IntegrationChartInternalNameType" + }, + "metadata": { + "$ref": "#/definitions/malak.IntegrationChartMetadata" + }, + "reference": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_facing_name": { + "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "workspace_integration_id": { + "type": "string" + } + } + }, + "malak.IntegrationChartInternalNameType": { + "type": "string", + "enum": [ + "mercury_account", + "mercury_account_transaction", + "brex_account", + "brex_account_transaction" + ], + "x-enum-varnames": [ + "IntegrationChartInternalNameTypeMercuryAccount", + "IntegrationChartInternalNameTypeMercuryAccountTransaction", + "IntegrationChartInternalNameTypeBrexAccount", + "IntegrationChartInternalNameTypeBrexAccountTransaction" + ] + }, + "malak.IntegrationChartMetadata": { + "type": "object", + "properties": { + "provider_id": { + "type": "string" + } + } + }, + "malak.IntegrationChartType": { + "type": "string", + "enum": [ + "bar", + "pie" + ], + "x-enum-varnames": [ + "IntegrationChartTypeBar", + "IntegrationChartTypePie" + ] + }, "malak.IntegrationMetadata": { "type": "object", "properties": { @@ -2959,6 +3369,9 @@ const docTemplate = `{ "embed_dashboard": { "type": "boolean" }, + "max_charts_per_dashboard": { + "type": "integer" + }, "share_dashboard_via_link": { "type": "boolean" } @@ -3441,6 +3854,17 @@ const docTemplate = `{ } } }, + "server.addChartToDashboardRequest": { + "type": "object", + "required": [ + "chart_reference" + ], + "properties": { + "chart_reference": { + "type": "string" + } + } + }, "server.addContactToListRequest": { "type": "object", "properties": { @@ -3503,6 +3927,21 @@ const docTemplate = `{ } } }, + "server.createDashboardRequest": { + "type": "object", + "required": [ + "description", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "server.createDeckRequest": { "type": "object", "properties": { @@ -3683,6 +4122,21 @@ const docTemplate = `{ } } }, + "server.fetchDashboardResponse": { + "type": "object", + "required": [ + "dashboard", + "message" + ], + "properties": { + "dashboard": { + "$ref": "#/definitions/malak.Dashboard" + }, + "message": { + "type": "string" + } + } + }, "server.fetchDeckResponse": { "type": "object", "required": [ @@ -3827,6 +4281,68 @@ const docTemplate = `{ } } }, + "server.listDashboardChartsResponse": { + "type": "object", + "required": [ + "charts", + "dashboard", + "message" + ], + "properties": { + "charts": { + "type": "array", + "items": { + "$ref": "#/definitions/malak.DashboardChart" + } + }, + "dashboard": { + "$ref": "#/definitions/malak.Dashboard" + }, + "message": { + "type": "string" + } + } + }, + "server.listDashboardResponse": { + "type": "object", + "required": [ + "dashboards", + "message", + "meta" + ], + "properties": { + "dashboards": { + "type": "array", + "items": { + "$ref": "#/definitions/malak.Dashboard" + } + }, + "message": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/server.meta" + } + } + }, + "server.listIntegrationChartsResponse": { + "type": "object", + "required": [ + "charts", + "message" + ], + "properties": { + "charts": { + "type": "array", + "items": { + "$ref": "#/definitions/malak.IntegrationChart" + } + }, + "message": { + "type": "string" + } + } + }, "server.listIntegrationResponse": { "type": "object", "required": [ diff --git a/swagger/swagger.json b/swagger/swagger.json index 260e3a23..5979aa9b 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -234,6 +234,67 @@ }, "type": "object" }, + "malak.Dashboard": { + "properties": { + "chart_count": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "type": "object" + }, + "malak.DashboardChart": { + "properties": { + "chart": { + "$ref": "#/components/schemas/malak.IntegrationChart" + }, + "chart_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "dashboard_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "workspace_integration_id": { + "type": "string" + } + }, + "type": "object" + }, "malak.Deck": { "properties": { "created_at": { @@ -351,6 +412,75 @@ }, "type": "object" }, + "malak.IntegrationChart": { + "properties": { + "chart_type": { + "$ref": "#/components/schemas/malak.IntegrationChartType" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "internal_name": { + "$ref": "#/components/schemas/malak.IntegrationChartInternalNameType" + }, + "metadata": { + "$ref": "#/components/schemas/malak.IntegrationChartMetadata" + }, + "reference": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_facing_name": { + "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "workspace_integration_id": { + "type": "string" + } + }, + "type": "object" + }, + "malak.IntegrationChartInternalNameType": { + "enum": [ + "mercury_account", + "mercury_account_transaction", + "brex_account", + "brex_account_transaction" + ], + "type": "string", + "x-enum-varnames": [ + "IntegrationChartInternalNameTypeMercuryAccount", + "IntegrationChartInternalNameTypeMercuryAccountTransaction", + "IntegrationChartInternalNameTypeBrexAccount", + "IntegrationChartInternalNameTypeBrexAccountTransaction" + ] + }, + "malak.IntegrationChartMetadata": { + "properties": { + "provider_id": { + "type": "string" + } + }, + "type": "object" + }, + "malak.IntegrationChartType": { + "enum": [ + "bar", + "pie" + ], + "type": "string", + "x-enum-varnames": [ + "IntegrationChartTypeBar", + "IntegrationChartTypePie" + ] + }, "malak.IntegrationMetadata": { "properties": { "endpoint": { @@ -424,6 +554,9 @@ "embed_dashboard": { "type": "boolean" }, + "max_charts_per_dashboard": { + "type": "integer" + }, "share_dashboard_via_link": { "type": "boolean" } @@ -908,6 +1041,17 @@ ], "type": "object" }, + "server.addChartToDashboardRequest": { + "properties": { + "chart_reference": { + "type": "string" + } + }, + "required": [ + "chart_reference" + ], + "type": "object" + }, "server.addContactToListRequest": { "properties": { "reference": { @@ -970,6 +1114,21 @@ }, "type": "object" }, + "server.createDashboardRequest": { + "properties": { + "description": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "description", + "title" + ], + "type": "object" + }, "server.createDeckRequest": { "properties": { "deck_url": { @@ -1150,6 +1309,21 @@ ], "type": "object" }, + "server.fetchDashboardResponse": { + "properties": { + "dashboard": { + "$ref": "#/components/schemas/malak.Dashboard" + }, + "message": { + "type": "string" + } + }, + "required": [ + "dashboard", + "message" + ], + "type": "object" + }, "server.fetchDeckResponse": { "properties": { "deck": { @@ -1294,6 +1468,68 @@ ], "type": "object" }, + "server.listDashboardChartsResponse": { + "properties": { + "charts": { + "items": { + "$ref": "#/components/schemas/malak.DashboardChart" + }, + "type": "array" + }, + "dashboard": { + "$ref": "#/components/schemas/malak.Dashboard" + }, + "message": { + "type": "string" + } + }, + "required": [ + "charts", + "dashboard", + "message" + ], + "type": "object" + }, + "server.listDashboardResponse": { + "properties": { + "dashboards": { + "items": { + "$ref": "#/components/schemas/malak.Dashboard" + }, + "type": "array" + }, + "message": { + "type": "string" + }, + "meta": { + "$ref": "#/components/schemas/server.meta" + } + }, + "required": [ + "dashboards", + "message", + "meta" + ], + "type": "object" + }, + "server.listIntegrationChartsResponse": { + "properties": { + "charts": { + "items": { + "$ref": "#/components/schemas/malak.IntegrationChart" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "required": [ + "charts", + "message" + ], + "type": "object" + }, "server.listIntegrationResponse": { "properties": { "integrations": { @@ -2342,6 +2578,368 @@ ] } }, + "/dashboards": { + "get": { + "parameters": [ + { + "description": "Page to query data from. Defaults to 1", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "Number to items to return. Defaults to 10 items", + "in": "query", + "name": "per_page", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.listDashboardResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "List dashboards", + "tags": [ + "dashboards" + ] + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.createDashboardRequest" + } + } + }, + "description": "dashboard request body", + "required": true, + "x-originalParamName": "message" + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.fetchDashboardResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "create a new dashboard", + "tags": [ + "dashboards" + ] + } + }, + "/dashboards/charts": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.listIntegrationChartsResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "List charts", + "tags": [ + "dashboards" + ] + } + }, + "/dashboards/{reference}": { + "get": { + "parameters": [ + { + "description": "dashboard unique reference.. e.g dashboard_", + "in": "path", + "name": "reference", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.listDashboardChartsResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "fetch dashboard", + "tags": [ + "dashboards" + ] + } + }, + "/dashboards/{reference}/charts": { + "put": { + "parameters": [ + { + "description": "dashboard unique reference.. e.g dashboard_", + "in": "path", + "name": "reference", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.addChartToDashboardRequest" + } + } + }, + "description": "dashboard request chart data", + "required": true, + "x-originalParamName": "message" + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "add a chart to a dashboard", + "tags": [ + "dashboards" + ] + } + }, "/decks": { "get": { "responses": { diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 6b925d7a..b1525280 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -159,6 +159,46 @@ components: additionalProperties: type: string type: object + malak.Dashboard: + properties: + chart_count: + type: integer + created_at: + type: string + description: + type: string + id: + type: string + reference: + type: string + title: + type: string + updated_at: + type: string + workspace_id: + type: string + type: object + malak.DashboardChart: + properties: + chart: + $ref: '#/components/schemas/malak.IntegrationChart' + chart_id: + type: string + created_at: + type: string + dashboard_id: + type: string + id: + type: string + reference: + type: string + updated_at: + type: string + workspace_id: + type: string + workspace_integration_id: + type: string + type: object malak.Deck: properties: created_at: @@ -236,6 +276,54 @@ components: updated_at: type: string type: object + malak.IntegrationChart: + properties: + chart_type: + $ref: '#/components/schemas/malak.IntegrationChartType' + created_at: + type: string + id: + type: string + internal_name: + $ref: '#/components/schemas/malak.IntegrationChartInternalNameType' + metadata: + $ref: '#/components/schemas/malak.IntegrationChartMetadata' + reference: + type: string + updated_at: + type: string + user_facing_name: + type: string + workspace_id: + type: string + workspace_integration_id: + type: string + type: object + malak.IntegrationChartInternalNameType: + enum: + - mercury_account + - mercury_account_transaction + - brex_account + - brex_account_transaction + type: string + x-enum-varnames: + - IntegrationChartInternalNameTypeMercuryAccount + - IntegrationChartInternalNameTypeMercuryAccountTransaction + - IntegrationChartInternalNameTypeBrexAccount + - IntegrationChartInternalNameTypeBrexAccountTransaction + malak.IntegrationChartMetadata: + properties: + provider_id: + type: string + type: object + malak.IntegrationChartType: + enum: + - bar + - pie + type: string + x-enum-varnames: + - IntegrationChartTypeBar + - IntegrationChartTypePie malak.IntegrationMetadata: properties: endpoint: @@ -293,6 +381,8 @@ components: properties: embed_dashboard: type: boolean + max_charts_per_dashboard: + type: integer share_dashboard_via_link: type: boolean type: object @@ -622,6 +712,13 @@ components: required: - message type: object + server.addChartToDashboardRequest: + properties: + chart_reference: + type: string + required: + - chart_reference + type: object server.addContactToListRequest: properties: reference: @@ -662,6 +759,16 @@ components: last_name: type: string type: object + server.createDashboardRequest: + properties: + description: + type: string + title: + type: string + required: + - description + - title + type: object server.createDeckRequest: properties: deck_url: @@ -782,6 +889,16 @@ components: - contact - message type: object + server.fetchDashboardResponse: + properties: + dashboard: + $ref: '#/components/schemas/malak.Dashboard' + message: + type: string + required: + - dashboard + - message + type: object server.fetchDeckResponse: properties: deck: @@ -879,6 +996,48 @@ components: - message - meta type: object + server.listDashboardChartsResponse: + properties: + charts: + items: + $ref: '#/components/schemas/malak.DashboardChart' + type: array + dashboard: + $ref: '#/components/schemas/malak.Dashboard' + message: + type: string + required: + - charts + - dashboard + - message + type: object + server.listDashboardResponse: + properties: + dashboards: + items: + $ref: '#/components/schemas/malak.Dashboard' + type: array + message: + type: string + meta: + $ref: '#/components/schemas/server.meta' + required: + - dashboards + - message + - meta + type: object + server.listIntegrationChartsResponse: + properties: + charts: + items: + $ref: '#/components/schemas/malak.IntegrationChart' + type: array + message: + type: string + required: + - charts + - message + type: object server.listIntegrationResponse: properties: integrations: @@ -1532,6 +1691,226 @@ paths: summary: Edit a contact list tags: - contacts + /dashboards: + get: + parameters: + - description: Page to query data from. Defaults to 1 + in: query + name: page + schema: + type: integer + - description: Number to items to return. Defaults to 10 items + in: query + name: per_page + schema: + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/server.listDashboardResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Unauthorized + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Internal Server Error + summary: List dashboards + tags: + - dashboards + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/server.createDashboardRequest' + description: dashboard request body + required: true + x-originalParamName: message + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/server.fetchDashboardResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Unauthorized + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Internal Server Error + summary: create a new dashboard + tags: + - dashboards + /dashboards/{reference}: + get: + parameters: + - description: dashboard unique reference.. e.g dashboard_ + in: path + name: reference + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/server.listDashboardChartsResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Unauthorized + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Internal Server Error + summary: fetch dashboard + tags: + - dashboards + /dashboards/{reference}/charts: + put: + parameters: + - description: dashboard unique reference.. e.g dashboard_ + in: path + name: reference + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/server.addChartToDashboardRequest' + description: dashboard request chart data + required: true + x-originalParamName: message + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Unauthorized + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Internal Server Error + summary: add a chart to a dashboard + tags: + - dashboards + /dashboards/charts: + get: + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/server.listIntegrationChartsResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Unauthorized + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Internal Server Error + summary: List charts + tags: + - dashboards /decks: get: responses: diff --git a/web/ui/bun.lockb b/web/ui/bun.lockb index 5c4a1e1b..cde4a638 100755 Binary files a/web/ui/bun.lockb and b/web/ui/bun.lockb differ diff --git a/web/ui/package.json b/web/ui/package.json index d9a98792..ee11851b 100644 --- a/web/ui/package.json +++ b/web/ui/package.json @@ -86,7 +86,7 @@ "react-resizable-panels": "^2.1.6", "react-select": "^5.8.0", "react-window": "^1.8.10", - "recharts": "^2.13.3", + "recharts": "^2.15.1", "sonner": "^1.7.0", "swagger-typescript-api": "^13.0.22", "tailwind-merge": "^2.5.4", diff --git a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx new file mode 100644 index 00000000..fc8f7cda --- /dev/null +++ b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx @@ -0,0 +1,385 @@ +"use client"; + +import { Card } from "@/components/ui/card"; +import { + RiBarChart2Line, RiPieChartLine, RiSettings4Line, + RiArrowDownSLine, RiLoader4Line +} from "@remixicon/react"; +import { useParams } from "next/navigation"; +import { + Bar, BarChart, ResponsiveContainer, + XAxis, YAxis, Tooltip, PieChart, + Pie, Cell +} from "recharts"; +import { ChartContainer } from "@/components/ui/chart"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, + SheetFooter, +} from "@/components/ui/sheet"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import client from "@/lib/client"; +import { LIST_CHARTS, DASHBOARD_DETAIL } from "@/lib/query-constants"; +import type { + ServerAPIStatus, ServerListIntegrationChartsResponse, + ServerListDashboardChartsResponse, MalakDashboardChart +} from "@/client/Api"; +import { toast } from "sonner"; +import { AxiosError } from "axios"; + +// Mock data for bar charts +const revenueData = [ + { month: "Day 1", revenue: 2400 }, + { month: "Day 2", revenue: 1398 }, + { month: "Day 3", revenue: 9800 }, + { month: "Day 4", revenue: 3908 }, + { month: "Day 5", revenue: 4800 }, + { month: "Day 6", revenue: 3800 }, + { month: "Day 7", revenue: 5200 }, + { month: "Day 8", revenue: 4100 }, + { month: "Day 9", revenue: 6300 }, + { month: "Day 10", revenue: 5400 }, + { month: "Day 11", revenue: 4700 }, + { month: "Day 12", revenue: 3900 }, + { month: "Day 13", revenue: 5600 }, + { month: "Day 14", revenue: 4800 }, + { month: "Day 15", revenue: 6100 }, + { month: "Day 16", revenue: 5300 }, + { month: "Day 17", revenue: 4500 }, + { month: "Day 18", revenue: 3700 }, + { month: "Day 19", revenue: 5900 }, + { month: "Day 20", revenue: 4200 }, + { month: "Day 21", revenue: 6400 }, + { month: "Day 22", revenue: 5500 }, + { month: "Day 23", revenue: 4600 }, + { month: "Day 24", revenue: 3800 }, + { month: "Day 25", revenue: 5700 }, + { month: "Day 26", revenue: 4900 }, + { month: "Day 27", revenue: 6200 }, + { month: "Day 28", revenue: 5100 }, + { month: "Day 29", revenue: 4300 }, + { month: "Day 30", revenue: 3600 } +]; + +// Mock data for pie charts +const costData = [ + { name: "Infrastructure", value: 400, color: "#0088FE" }, + { name: "Marketing", value: 300, color: "#00C49F" }, + { name: "Development", value: 500, color: "#FFBB28" }, + { name: "Operations", value: 200, color: "#FF8042" }, +]; + +function ChartCard({ chart }: { chart: MalakDashboardChart }) { + const getChartIcon = (type: string) => { + switch (type) { + case "bar": + return ; + case "pie": + return ; + default: + return ; + } + }; + + const getChartData = (chart: MalakDashboardChart) => { + // TODO: Replace with real data from the chart's data source + return chart.chart?.chart_type === "bar" ? revenueData : costData; + }; + + if (!chart.chart) { + return null; + } + + return ( + +
+
+
+ {getChartIcon(chart.chart.chart_type || "bar")} +
+
+

{chart.chart.user_facing_name}

+

{chart.chart.internal_name}

+
+
+ + + + + + Edit Chart + Duplicate + Delete + + +
+
+ {chart.chart.chart_type === "bar" ? ( + + + + + + + + + + + ) : ( + + + + `${name} ${(percent * 100).toFixed(0)}%`} + outerRadius={60} + dataKey="value" + > + {costData.map((entry, index) => ( + + ))} + + + + + + )} +
+
+ ); +} + +export default function DashboardPage() { + const params = useParams(); + const dashboardId = params.slug as string; + + const [isOpen, setIsOpen] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [selectedChart, setSelectedChart] = useState(""); + const [selectedChartLabel, setSelectedChartLabel] = useState(""); + + const { data: dashboardData, isLoading: isLoadingDashboard } = useQuery({ + queryKey: [DASHBOARD_DETAIL, dashboardId], + queryFn: async () => { + const response = await client.dashboards.dashboardsDetail(dashboardId); + return response.data; + }, + }); + + const { data: chartsData, isLoading: isLoadingCharts } = useQuery({ + queryKey: [LIST_CHARTS], + queryFn: async () => { + const response = await client.dashboards.chartsList(); + return response.data; + }, + enabled: isPopoverOpen, + }); + + const addChartMutation = useMutation({ + mutationFn: async (chartReference: string) => { + const response = await client.dashboards.chartsUpdate(dashboardId, { + chart_reference: chartReference + }); + return response.data; + }, + onSuccess: (data) => { + setSelectedChart(""); + setSelectedChartLabel(""); + setIsOpen(false); + toast.success(data.message); + }, + onError: (err: AxiosError): void => { + toast.error(err?.response?.data?.message || "Failed to add chart to dashboard"); + } + }); + + const barCharts = chartsData?.charts?.filter(chart => chart.chart_type === "bar") ?? []; + const pieCharts = chartsData?.charts?.filter(chart => chart.chart_type === "pie") ?? []; + + const handleAddChart = () => { + if (!selectedChart) { + toast.warning("Select a chart before adding to dashboard") + return + }; + + addChartMutation.mutate(selectedChart); + }; + + if (isLoadingDashboard) { + return ( +
+
+ +

Loading dashboard...

+
+
+ ); + } + + if (!dashboardData?.dashboard) { + return ( +
+
+

Dashboard not found

+

The dashboard you're looking for doesn't exist.

+
+
+ ); + } + + return ( +
+
+
+

{dashboardData.dashboard.title}

+

{dashboardData.dashboard.description}

+
+ + + + + + + Add Chart + + Select a chart to add to your dashboard + + +
+
+
+ + + + + + + + + + No charts found. + {isLoadingCharts ? ( + + + Loading available charts... + + ) : ( + <> + {barCharts.length > 0 && ( + + {barCharts.map(chart => ( + { + setSelectedChart(chart.reference || ""); + setSelectedChartLabel(chart.user_facing_name || ""); + setIsPopoverOpen(false); + }} + className="flex items-center gap-2" + > + + {chart.user_facing_name} + + ))} + + )} + {pieCharts.length > 0 && ( + + {pieCharts.map(chart => ( + { + setSelectedChart(chart.reference || ""); + setSelectedChartLabel(chart.user_facing_name || ""); + setIsPopoverOpen(false); + }} + className="flex items-center gap-2" + > + + {chart.user_facing_name} + + ))} + + )} + + )} + + + + + {selectedChart && ( +

+ Selected: {selectedChartLabel} +

+ )} +
+
+
+ + + +
+
+
+ +
+ {dashboardData.charts.map((chart) => ( + + ))} +
+
+ ); +} diff --git a/web/ui/src/app/(main)/dashboards/page.tsx b/web/ui/src/app/(main)/dashboards/page.tsx new file mode 100644 index 00000000..ad77dc43 --- /dev/null +++ b/web/ui/src/app/(main)/dashboards/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import ListDashboards from "@/components/ui/dashboards/list"; +import CreateDashboardModal from "@/components/ui/dashboards/create-modal"; + +export default function Page() { + return ( + <> +
+
+
+
+

+ Your dashboards +

+

+ View and manage dashboards created from the data of your integrations +

+
+ +
+ +
+
+
+ +
+ +
+
+ + ); +} diff --git a/web/ui/src/app/(main)/overview/page.tsx b/web/ui/src/app/(main)/overview/page.tsx index 86fe8237..117f572d 100644 --- a/web/ui/src/app/(main)/overview/page.tsx +++ b/web/ui/src/app/(main)/overview/page.tsx @@ -2,20 +2,22 @@ import { overviews } from "@/data/overview-data"; import type { OverviewData } from "@/data/schema"; import React from "react"; -import type { DateRange } from "react-day-picker"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Calendar } from "@/components/ui/calendar"; -import { addDays, format, isWithinInterval, parseISO, subDays } from "date-fns"; +import { format, parseISO } from "date-fns"; import { ArrowDown, ArrowUp, Users, - DollarSign, + Mail, Building, FileText, TrendingUp, Presentation, - BarChart, + Eye, + Clock, + Lock, + Share2, + BarChart4, Activity } from "lucide-react"; import { @@ -28,22 +30,12 @@ import { ResponsiveContainer, AreaChart, Area, + BarChart, + Bar, } from "recharts"; export default function Overview() { - const [date, setDate] = React.useState({ - from: subDays(new Date(), 30), - to: new Date(), - }); - - const filteredData = React.useMemo(() => { - if (!date?.from || !date?.to) return overviews; - return overviews.filter((item) => { - const itemDate = parseISO(item.date); - return isWithinInterval(itemDate, { start: date.from!, end: date.to! }); - }); - }, [date]); - + const filteredData = overviews; const latestMetrics = filteredData[filteredData.length - 1]; const previousMetrics = filteredData[filteredData.length - 2]; @@ -52,91 +44,82 @@ export default function Overview() { return ((current - previous) / previous) * 100; }; - const formatCurrency = (value: number) => { - if (value >= 1000000) { - return `$${(value / 1000000).toFixed(1)}M`; - } - if (value >= 1000) { - return `$${(value / 1000).toFixed(1)}K`; - } - return `$${value}`; - }; - const metrics = [ { title: "Active Investors", value: latestMetrics?.["Active Investors"] || 0, - change: getPercentageChange( - latestMetrics?.["Active Investors"] || 0, - previousMetrics?.["Active Investors"] || 0 - ), icon: Users, format: (v: number) => v.toString(), }, { - title: "Total Funding", - value: latestMetrics?.["Total Funding"] || 0, - change: getPercentageChange( - latestMetrics?.["Total Funding"] || 0, - previousMetrics?.["Total Funding"] || 0 - ), - icon: DollarSign, - format: formatCurrency, + title: "Update Opens", + value: latestMetrics?.["Update Opens"] || 0, + icon: Mail, + format: (v: number) => v.toString(), }, { - title: "Company Valuation", - value: latestMetrics?.["Company Valuation"] || 0, - change: getPercentageChange( - latestMetrics?.["Company Valuation"] || 0, - previousMetrics?.["Company Valuation"] || 0 - ), - icon: TrendingUp, - format: formatCurrency, - }, + title: "Deck Views", + value: latestMetrics?.["Deck Views"] || 0, + icon: Eye, + format: (v: number) => v.toString(), + } + ]; + + const contentMetrics = [ { - title: "Team Size", - value: latestMetrics?.["Team Members"] || 0, - change: getPercentageChange( - latestMetrics?.["Team Members"] || 0, - previousMetrics?.["Team Members"] || 0 - ), - icon: Building, + title: "Active Decks", + value: latestMetrics?.["Active Decks"] || 0, + icon: Presentation, format: (v: number) => v.toString(), }, { - title: "New Pitches", - value: latestMetrics?.["New Pitches"] || 0, - change: getPercentageChange( - latestMetrics?.["New Pitches"] || 0, - previousMetrics?.["New Pitches"] || 0 - ), - icon: Presentation, + title: "Protected Content", + value: latestMetrics?.["Protected Content"] || 0, + icon: Lock, format: (v: number) => v.toString(), }, { - title: "Document Updates", - value: latestMetrics?.["Document Updates"] || 0, - change: getPercentageChange( - latestMetrics?.["Document Updates"] || 0, - previousMetrics?.["Document Updates"] || 0 - ), - icon: FileText, + title: "Shared Links", + value: latestMetrics?.["Shared Links"] || 0, + icon: Share2, format: (v: number) => v.toString(), + } + ]; + + const recentActivity = [ + { + type: "update", + title: "Q4 2023 Investor Update", + metric: "85% open rate", + time: "2 hours ago" + }, + { + type: "deck", + title: "Series A Pitch Deck", + metric: "12 new views", + time: "5 hours ago" }, + { + type: "investor", + title: "New Investor Added", + metric: "Total: 45 investors", + time: "1 day ago" + }, + { + type: "share", + title: "Financial Model Shared", + metric: "3 accesses", + time: "2 days ago" + } ]; return (
-

Overview

- +

Investor Relations Overview

+

Engagement Metrics

{metrics.map((metric) => ( @@ -148,30 +131,32 @@ export default function Overview() {
{metric.format(metric.value)}
-
- {metric.change > 0 ? ( - - ) : ( - - )} - 0 ? "text-green-500" : "text-red-500" - }`} - > - {Math.abs(metric.change).toFixed(1)}% - - vs previous day -
))}
-
+

Content Overview

+
+ {contentMetrics.map((metric) => ( + + + + + {metric.title} + + + +
{metric.format(metric.value)}
+
+
+ ))} +
+ +
- Funding & Valuation + Investor Engagement Trends
@@ -188,7 +173,6 @@ export default function Overview() { labelFormatter={(value) => format(parseISO(value as string), "MMM dd, yyyy") } - formatter={(value: number, name: string) => [formatCurrency(value), name]} contentStyle={{ backgroundColor: "rgba(0, 0, 0, 0.8)", border: "none", @@ -198,19 +182,19 @@ export default function Overview() { /> @@ -221,63 +205,39 @@ export default function Overview() { - - Activity Trends + + Recent Activity -
- - - - format(parseISO(value), "MMM dd")} - stroke="#888888" - /> - - - format(parseISO(value as string), "MMM dd, yyyy") - } - contentStyle={{ - backgroundColor: "rgba(0, 0, 0, 0.8)", - border: "none", - borderRadius: "4px", - color: "#fff", - }} - /> - - - - +
+ {recentActivity.map((activity, index) => ( +
+ {activity.type === "update" && } + {activity.type === "deck" && } + {activity.type === "investor" && } + {activity.type === "share" && } +
+

{activity.title}

+
+ {activity.metric} + + {activity.time} +
+
+
+ ))}
- - Growth Overview + + Content Performance
- + - - + - +
diff --git a/web/ui/src/client/Api.ts b/web/ui/src/client/Api.ts index aa34ab29..0c49ab8d 100644 --- a/web/ui/src/client/Api.ts +++ b/web/ui/src/client/Api.ts @@ -104,6 +104,29 @@ export enum MalakContactShareItemType { export type MalakCustomContactMetadata = Record; +export interface MalakDashboard { + chart_count?: number; + created_at?: string; + description?: string; + id?: string; + reference?: string; + title?: string; + updated_at?: string; + workspace_id?: string; +} + +export interface MalakDashboardChart { + chart?: MalakIntegrationChart; + chart_id?: string; + created_at?: string; + dashboard_id?: string; + id?: string; + reference?: string; + updated_at?: string; + workspace_id?: string; + workspace_integration_id?: string; +} + export interface MalakDeck { created_at?: string; created_by?: string; @@ -147,6 +170,35 @@ export interface MalakIntegration { updated_at?: string; } +export interface MalakIntegrationChart { + chart_type?: MalakIntegrationChartType; + created_at?: string; + id?: string; + internal_name?: MalakIntegrationChartInternalNameType; + metadata?: MalakIntegrationChartMetadata; + reference?: string; + updated_at?: string; + user_facing_name?: string; + workspace_id?: string; + workspace_integration_id?: string; +} + +export enum MalakIntegrationChartInternalNameType { + IntegrationChartInternalNameTypeMercuryAccount = "mercury_account", + IntegrationChartInternalNameTypeMercuryAccountTransaction = "mercury_account_transaction", + IntegrationChartInternalNameTypeBrexAccount = "brex_account", + IntegrationChartInternalNameTypeBrexAccountTransaction = "brex_account_transaction", +} + +export interface MalakIntegrationChartMetadata { + provider_id?: string; +} + +export enum MalakIntegrationChartType { + IntegrationChartTypeBar = "bar", + IntegrationChartTypePie = "pie", +} + export interface MalakIntegrationMetadata { endpoint?: string; } @@ -188,6 +240,7 @@ export interface MalakPlan { export interface MalakPlanMetadata { dashboard?: { embed_dashboard?: boolean; + max_charts_per_dashboard?: number; share_dashboard_via_link?: boolean; }; data_room?: { @@ -386,6 +439,10 @@ export interface ServerAPIStatus { message: string; } +export interface ServerAddChartToDashboardRequest { + chart_reference: string; +} + export interface ServerAddContactToListRequest { reference?: string; } @@ -409,6 +466,11 @@ export interface ServerCreateContactRequest { last_name?: string; } +export interface ServerCreateDashboardRequest { + description: string; + title: string; +} + export interface ServerCreateDeckRequest { deck_url?: string; title?: string; @@ -466,6 +528,11 @@ export interface ServerFetchContactResponse { message: string; } +export interface ServerFetchDashboardResponse { + dashboard: MalakDashboard; + message: string; +} + export interface ServerFetchDeckResponse { deck: MalakDeck; message: string; @@ -509,6 +576,23 @@ export interface ServerListContactsResponse { meta: ServerMeta; } +export interface ServerListDashboardChartsResponse { + charts: MalakDashboardChart[]; + dashboard: MalakDashboard; + message: string; +} + +export interface ServerListDashboardResponse { + dashboards: MalakDashboard[]; + message: string; + meta: ServerMeta; +} + +export interface ServerListIntegrationChartsResponse { + charts: MalakIntegrationChart[]; + message: string; +} + export interface ServerListIntegrationResponse { integrations: MalakWorkspaceIntegration[]; message: string; @@ -923,6 +1007,100 @@ export class Api extends HttpClient + this.request({ + path: `/dashboards`, + method: "GET", + query: query, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags dashboards + * @name DashboardsCreate + * @summary create a new dashboard + * @request POST:/dashboards + */ + dashboardsCreate: (data: ServerCreateDashboardRequest, params: RequestParams = {}) => + this.request({ + path: `/dashboards`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags dashboards + * @name DashboardsDetail + * @summary fetch dashboard + * @request GET:/dashboards/{reference} + */ + dashboardsDetail: (reference: string, params: RequestParams = {}) => + this.request({ + path: `/dashboards/${reference}`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags dashboards + * @name ChartsUpdate + * @summary add a chart to a dashboard + * @request PUT:/dashboards/{reference}/charts + */ + chartsUpdate: (reference: string, data: ServerAddChartToDashboardRequest, params: RequestParams = {}) => + this.request({ + path: `/dashboards/${reference}/charts`, + method: "PUT", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags dashboards + * @name ChartsList + * @summary List charts + * @request GET:/dashboards/charts + */ + chartsList: (params: RequestParams = {}) => + this.request({ + path: `/dashboards/charts`, + method: "GET", + format: "json", + ...params, + }), + }; decks = { /** * No description diff --git a/web/ui/src/components/providers/user.tsx b/web/ui/src/components/providers/user.tsx index ac446cad..fbee70d5 100644 --- a/web/ui/src/components/providers/user.tsx +++ b/web/ui/src/components/providers/user.tsx @@ -102,7 +102,13 @@ export default function UserProvider({ }, [token, isRehydrated]); if (loading) { - return
Loading...
; + return ( +
+
+
+
+
+ ); } return children; diff --git a/web/ui/src/components/ui/custom/loader/skeleton.tsx b/web/ui/src/components/ui/custom/loader/skeleton.tsx index 59a1af3c..6bbf1776 100644 --- a/web/ui/src/components/ui/custom/loader/skeleton.tsx +++ b/web/ui/src/components/ui/custom/loader/skeleton.tsx @@ -3,13 +3,15 @@ import BaseSkeleton from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; const Skeleton = ({ count }: { count: number }) => { + const { theme } = useTheme(); - const { theme } = useTheme() - - return ; + return ( + + ); }; export default Skeleton; diff --git a/web/ui/src/components/ui/dashboards/create-modal.tsx b/web/ui/src/components/ui/dashboards/create-modal.tsx new file mode 100644 index 00000000..45fded32 --- /dev/null +++ b/web/ui/src/components/ui/dashboards/create-modal.tsx @@ -0,0 +1,153 @@ +"use client" + +import type { ServerAPIStatus } from "@/client/Api"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RiAddLine } from "@remixicon/react"; +import { useState } from "react"; +import { type SubmitHandler, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import client from "@/lib/client"; +import { CREATE_DASHBOARD, LIST_DASHBOARDS } from "@/lib/query-constants"; + +type CreateDashboardInput = { + name: string; + description?: string; +}; + +const schema = yup.object().shape({ + name: yup.string().required("Dashboard name is required"), + description: yup.string(), +}); + +export default function CreateDashboardModal() { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); + + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: yupResolver(schema), + }); + + const createMutation = useMutation({ + mutationKey: [CREATE_DASHBOARD], + mutationFn: (data: CreateDashboardInput) => { + return client.dashboards.dashboardsCreate({ + title: data.name, + ...data + }) + }, + onSuccess: ({ data }) => { + toast.success(data.message); + setOpen(false); + reset(); + queryClient.invalidateQueries({ queryKey: [LIST_DASHBOARDS] }); + }, + onError: (err: AxiosError) => { + let msg = err.message; + if (err.response?.data) { + msg = err.response.data.message; + } + toast.error(msg); + }, + retry: false, + gcTime: Number.POSITIVE_INFINITY, + onSettled: () => setLoading(false), + }); + + const onSubmit: SubmitHandler = (data) => { + setLoading(true); + createMutation.mutate(data); + }; + + return ( + + + + + +
+ + Create new dashboard + + Create a new dashboard to visualize and track your data + + + +
+ + + {errors.name && ( +

+ {errors.name.message} +

+ )} +
+ +
+ + +
+ + + + + + + +
+
+
+ ); +} diff --git a/web/ui/src/components/ui/dashboards/list.tsx b/web/ui/src/components/ui/dashboards/list.tsx new file mode 100644 index 00000000..ab04163e --- /dev/null +++ b/web/ui/src/components/ui/dashboards/list.tsx @@ -0,0 +1,135 @@ +"use client" + +import { Card } from "@/components/ui/card"; +import { RiDashboardLine } from "@remixicon/react"; +import { format } from "date-fns"; +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import client from "@/lib/client"; +import { LIST_DASHBOARDS } from "@/lib/query-constants"; +import { Button } from "@/components/ui/button"; +import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react"; +import { useState } from "react"; +import type { MalakDashboard, ServerListDashboardResponse } from "@/client/Api"; + +export default function ListDashboards() { + const [page, setPage] = useState(1); + const perPage = 12; + + const { data, isLoading, isError } = useQuery({ + queryKey: [LIST_DASHBOARDS, page], + queryFn: async () => { + const response = await client.dashboards.dashboardsList({ + page, + per_page: perPage, + }); + return response.data; + }, + }); + + if (isLoading) { + return ( + +
+
+ +
+

+ Loading dashboards... +

+
+
+ ); + } + + if (isError) { + return ( + +
+
+ +
+

+ Error loading dashboards +

+

+ Please try again later. +

+
+
+ ); + } + + if (!data?.dashboards?.length) { + return ( + +
+
+ +
+

+ No dashboards yet +

+

+ Create your first dashboard to visualize data from your integrations. +

+
+
+ ); + } + + const totalPages = Math.ceil(data.meta.paging.total / perPage); + + return ( +
+
+ {data.dashboards.map((dashboard: MalakDashboard) => ( + + +
+

{dashboard.title}

+

{dashboard.description}

+
+ +
+
+ + {dashboard.chart_count} charts +
+ Created {format(new Date(dashboard.created_at!), "MMM d, yyyy")} +
+
+ + ))} +
+ + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ ); +} diff --git a/web/ui/src/components/ui/navigation/ModalAddWorkspace.tsx b/web/ui/src/components/ui/navigation/ModalAddWorkspace.tsx index dd9116e7..a69cd9d7 100644 --- a/web/ui/src/components/ui/navigation/ModalAddWorkspace.tsx +++ b/web/ui/src/components/ui/navigation/ModalAddWorkspace.tsx @@ -19,11 +19,11 @@ import client from "@/lib/client"; import { CREATE_WORKSPACE } from "@/lib/query-constants"; import useWorkspacesStore from "@/store/workspace"; import { yupResolver } from "@hookform/resolvers/yup"; -import { RiAddBoxLine, RiAddLargeLine } from "@remixicon/react"; +import { RiAddLargeLine } from "@remixicon/react"; import { useMutation } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { useRouter } from "next/navigation"; -import { useState, useRef, useEffect } from "react"; +import { useState, useEffect } from "react"; import { type SubmitHandler, useForm } from "react-hook-form"; import { toast } from "sonner"; import * as yup from "yup"; diff --git a/web/ui/src/components/ui/navigation/navlist.ts b/web/ui/src/components/ui/navigation/navlist.ts index 76794350..6a840ffc 100644 --- a/web/ui/src/components/ui/navigation/navlist.ts +++ b/web/ui/src/components/ui/navigation/navlist.ts @@ -2,6 +2,7 @@ import { RiArchiveStackLine, RiBook3Line, RiContactsLine, + RiDashboardHorizontalLine, RiHome2Line, RiMoneyDollarCircleLine, RiPieChartLine, @@ -30,6 +31,16 @@ export const links = [ url: "/contacts", icon: RiContactsLine, }, + { + title: "Integrations", + url: "/integrations", + icon: RiPlug2Line + }, + { + title: "Data Dashboards", + url: "/dashboards", + icon: RiDashboardHorizontalLine + }, { title: "Fundraising", url: "/fundraising", @@ -40,11 +51,6 @@ export const links = [ url: "/captable", icon: RiPieChartLine, }, - { - title: "Integrations", - url: "/integrations", - icon: RiPlug2Line - }, { title: "Settings", url: "/settings", diff --git a/web/ui/src/lib/query-constants.ts b/web/ui/src/lib/query-constants.ts index cc708315..d7945960 100644 --- a/web/ui/src/lib/query-constants.ts +++ b/web/ui/src/lib/query-constants.ts @@ -31,3 +31,7 @@ export const PING_INTEGRATION = 'PING_INTEGRATION' as const; export const ENABLE_INTEGRATION = 'ENABLE_INTEGRATION' as const; export const UPDATE_INTEGRATION_SETTINGS = 'UPDATE_INTEGRATION_SETTINGS' as const; export const DISABLE_INTEGRATION = 'DISABLE_INTEGRATION' as const; +export const CREATE_DASHBOARD = 'CREATE_DASHBOARD' as const; +export const LIST_DASHBOARDS = "LIST_DASHBOARDS" as const; +export const LIST_CHARTS = "LIST_CHARTS"; +export const DASHBOARD_DETAIL = "DASHBOARD_DETAIL" as const; diff --git a/web/ui/tailwind.config.ts b/web/ui/tailwind.config.ts index dd183a06..4cadff75 100644 --- a/web/ui/tailwind.config.ts +++ b/web/ui/tailwind.config.ts @@ -117,55 +117,55 @@ const config: Config = { sm: 'calc(var(--radius) - 4px)' }, colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', + background: "hsl(0 0% 100%)", + foreground: "hsl(222 47% 11%)", card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' + DEFAULT: "hsl(0 0% 100%)", + foreground: "hsl(222 47% 11%)", }, popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' + DEFAULT: "hsl(0 0% 100%)", + foreground: "hsl(222 47% 11%)", }, primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' + DEFAULT: "hsl(160 84% 39%)", + foreground: "hsl(0 0% 100%)", }, secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' + DEFAULT: "hsl(210 40% 96.1%)", + foreground: "hsl(222 47% 11%)", }, muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' + DEFAULT: "hsl(210 40% 96.1%)", + foreground: "hsl(215.4 16.3% 46.9%)", }, accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' + DEFAULT: "hsl(210 40% 96.1%)", + foreground: "hsl(222 47% 11%)", }, destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))' + DEFAULT: "hsl(0 84.2% 60.2%)", + foreground: "hsl(210 40% 98%)", }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', + border: "hsl(214.3 31.8% 91.4%)", + input: "hsl(214.3 31.8% 91.4%)", + ring: "hsl(222 47% 11%)", chart: { - '1': 'hsl(var(--chart-1))', - '2': 'hsl(var(--chart-2))', - '3': 'hsl(var(--chart-3))', - '4': 'hsl(var(--chart-4))', - '5': 'hsl(var(--chart-5))' + "1": "hsl(222 47% 11%)", + "2": "hsl(215.4 16.3% 46.9%)", + "3": "hsl(214.3 31.8% 91.4%)", + "4": "hsl(210 40% 96.1%)", + "5": "hsl(0 0% 100%)", }, sidebar: { - DEFAULT: 'hsl(var(--sidebar-background))', - foreground: 'hsl(var(--sidebar-foreground))', - primary: 'hsl(var(--sidebar-primary))', - 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', - accent: 'hsl(var(--sidebar-accent))', - 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', - border: 'hsl(var(--sidebar-border))', - ring: 'hsl(var(--sidebar-ring))' + DEFAULT: "hsl(0 0% 100%)", + foreground: "hsl(222 47% 11%)", + primary: "hsl(222 47% 11%)", + "primary-foreground": "hsl(0 0% 100%)", + accent: "hsl(210 40% 96.1%)", + "accent-foreground": "hsl(222 47% 11%)", + border: "hsl(214.3 31.8% 91.4%)", + ring: "hsl(222 47% 11%)", } } }