Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
179f9b6
first version
pjain1 Jan 19, 2026
fa50fca
features
pjain1 Jan 20, 2026
89b2fd3
fix
pjain1 Jan 20, 2026
2f9847f
proto for api
pjain1 Jan 20, 2026
d2fd159
mjml template
pjain1 Jan 20, 2026
cc3b9ca
Merge branch 'main' into ai_reports
pjain1 Jan 20, 2026
44cc84b
tweaks
pjain1 Jan 20, 2026
a0700bd
tweaks
pjain1 Jan 21, 2026
51a98cc
tweak
pjain1 Jan 21, 2026
01bf8f4
refactor
pjain1 Jan 22, 2026
a7b4198
recipient mode support for ai reports
pjain1 Jan 26, 2026
321f32f
comment
pjain1 Jan 26, 2026
8e25370
lint
pjain1 Jan 26, 2026
986722a
fix
pjain1 Jan 26, 2026
0204856
creator mode support
pjain1 Jan 26, 2026
ebfe286
Merge branch 'main' into ai_reports
pjain1 Jan 26, 2026
0f6e8d1
review comments
pjain1 Jan 29, 2026
cebf8e3
Merge branch 'main' into ai_reports
pjain1 Jan 29, 2026
9312804
fix test
pjain1 Jan 29, 2026
dfb6720
email template
pjain1 Jan 29, 2026
757ca56
refactor
pjain1 Feb 2, 2026
a570d4f
lint
pjain1 Feb 2, 2026
bdaea62
refactor
pjain1 Feb 2, 2026
543f94a
tz
pjain1 Feb 2, 2026
170521f
resolve time ranges with mv if provided
pjain1 Feb 2, 2026
6716608
review comments
pjain1 Feb 8, 2026
49d2a7a
review comments
pjain1 Feb 8, 2026
be8bf72
Merge branch 'main' into ai_reports
pjain1 Feb 8, 2026
2a2cfa4
review comment
pjain1 Feb 10, 2026
ac22279
Merge branch 'main' into ai_reports
pjain1 Feb 10, 2026
b4a1cbb
resolve conflict
pjain1 Feb 11, 2026
3c18d0a
self review
pjain1 Feb 11, 2026
5a05ab5
test
pjain1 Feb 11, 2026
aef4ee5
Merge branch 'main' into ai_reports
pjain1 Feb 12, 2026
2015753
review comments
pjain1 Feb 18, 2026
bcbece2
Merge branch 'main' into ai_reports
pjain1 Feb 18, 2026
943ef08
UI scheduled report to use resolver props
pjain1 Feb 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions admin/server/alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ func (s *Server) GetAlertMeta(ctx context.Context, req *adminv1.GetAlertMetaRequ
if req.QueryFor != nil {
switch forVal := req.QueryFor.(type) {
case *adminv1.GetAlertMetaRequest_QueryForUserId:
attr, _, err = s.getAttributesForUser(ctx, proj.OrganizationID, proj.ID, forVal.QueryForUserId, "")
attr, _, _, err = s.getAttributesForUser(ctx, proj.OrganizationID, proj.ID, forVal.QueryForUserId, "")
if err != nil {
return nil, err
}
case *adminv1.GetAlertMetaRequest_QueryForUserEmail:
attr, _, err = s.getAttributesForUser(ctx, proj.OrganizationID, proj.ID, "", forVal.QueryForUserEmail)
attr, _, _, err = s.getAttributesForUser(ctx, proj.OrganizationID, proj.ID, "", forVal.QueryForUserEmail)
if err != nil {
return nil, err
}
Expand Down
26 changes: 15 additions & 11 deletions admin/server/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -955,7 +955,7 @@ func (s *Server) getResourceRestrictionsForUser(ctx context.Context, projID, use
// The caller should only provide one of userID or userEmail (if both or neither are set, an error will be returned).
// NOTE: The value returned from this function must be valid for structpb.NewStruct (e.g. must use []any for slices, not a more specific slice type).
func (s *Server) getAttributesAndResourceRestrictionsForUser(ctx context.Context, orgID, projID, userID, userEmail string) (map[string]any, bool, []database.ResourceName, error) {
attr, userID, err := s.getAttributesForUser(ctx, orgID, projID, userID, userEmail)
attr, userID, _, err := s.getAttributesForUser(ctx, orgID, projID, userID, userEmail)
if err != nil {
return nil, false, nil, err
}
Expand All @@ -973,17 +973,21 @@ func (s *Server) getAttributesAndResourceRestrictionsForUser(ctx context.Context
return attr, restrictResources, resources, nil
}

// getAttributesForUser returns a map of attributes for a given user and project and the userID.
// getAttributesForUser returns
// 1. map of attributes for a given user and project and
// 2. userID
// 3. whether the user has read access to prod deployment
//
// The caller should only provide one of userID or userEmail (if both or neither are set, an error will be returned).
// NOTE: The value returned from this function must be valid for structpb.NewStruct (e.g. must use []any for slices, not a more specific slice type).
func (s *Server) getAttributesForUser(ctx context.Context, orgID, projID, userID, userEmail string) (map[string]any, string, error) {
func (s *Server) getAttributesForUser(ctx context.Context, orgID, projID, userID, userEmail string) (map[string]any, string, bool, error) {
if userID == "" && userEmail == "" {
return nil, "", errors.New("must provide either userID or userEmail")
return nil, "", false, errors.New("must provide either userID or userEmail")
}

if userEmail != "" {
if userID != "" {
return nil, "", errors.New("must provide either userID or userEmail, not both")
return nil, "", false, errors.New("must provide either userID or userEmail, not both")
}

user, err := s.admin.DB.FindUserByEmail(ctx, userEmail)
Expand All @@ -996,28 +1000,28 @@ func (s *Server) getAttributesForUser(ctx context.Context, orgID, projID, userID
"email": userEmail,
"domain": userEmail[strings.LastIndex(userEmail, "@")+1:],
"admin": false,
}, "", nil
}, "", false, nil
}
return nil, "", err
return nil, "", false, err
}

userID = user.ID
}

forOrgPerms, err := s.admin.OrganizationPermissionsForUser(ctx, orgID, userID)
if err != nil {
return nil, "", err
return nil, "", false, err
}

forProjPerms, err := s.admin.ProjectPermissionsForUser(ctx, projID, userID, forOrgPerms)
if err != nil {
return nil, "", err
return nil, "", false, err
}

attr, err := s.jwtAttributesForUser(ctx, userID, orgID, forProjPerms)
if err != nil {
return nil, "", err
return nil, "", false, err
}

return attr, userID, nil
return attr, userID, forProjPerms.ReadProd, nil
}
109 changes: 92 additions & 17 deletions admin/server/reports.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"path"
"regexp"
Expand All @@ -22,6 +23,7 @@ import (
"golang.org/x/exp/slices"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
"gopkg.in/yaml.v3"
)

Expand Down Expand Up @@ -56,7 +58,7 @@ func (s *Server) GetReportMeta(ctx context.Context, req *adminv1.GetReportMetaRe
return nil, status.Error(codes.InvalidArgument, err.Error())
}

urls := make(map[string]*adminv1.GetReportMetaResponse_URLs)
delivery := make(map[string]*adminv1.GetReportMetaResponse_DeliveryMeta)

var recipients []string
recipients = append(recipients, req.EmailRecipients...)
Expand Down Expand Up @@ -84,6 +86,17 @@ func (s *Server) GetReportMeta(ctx context.Context, req *adminv1.GetReportMetaRe
return nil, fmt.Errorf("failed to issue magic auth tokens: %w", err)
}

var ownerAttrs *structpb.Struct
if req.OwnerId != "" {
attr, _, _, err := s.getAttributesForUser(ctx, proj.OrganizationID, proj.ID, req.OwnerId, "")
if err != nil {
return nil, err
}
ownerAttrs, err = structpb.NewStruct(attr)
if err != nil {
return nil, err
}
}
// Generate URLs for each recipient based on web open mode, and whether they are the owner -
// Owner does not get a token in recipient mode and does not get an unsubscribe link.
// Recipients in creator mode get a token and an unsubscribe link.
Expand All @@ -94,43 +107,68 @@ func (s *Server) GetReportMeta(ctx context.Context, req *adminv1.GetReportMetaRe
if recipient == ownerEmail {
if webOpenMode == WebOpenModeRecipient {
// owner in recipient mode gets plain open and export url without token as token does not have any access
urls[recipient] = &adminv1.GetReportMetaResponse_URLs{
delivery[recipient] = &adminv1.GetReportMetaResponse_DeliveryMeta{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, "", req.ExecutionTime.AsTime()),
ExportUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportExport(org.Name, proj.Name, req.Report, ""),
EditUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportEdit(org.Name, proj.Name, req.Report),
UserId: req.OwnerId,
UserAttrs: ownerAttrs,
}
} else {
urls[recipient] = &adminv1.GetReportMetaResponse_URLs{
delivery[recipient] = &adminv1.GetReportMetaResponse_DeliveryMeta{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, tokens[recipient], req.ExecutionTime.AsTime()),
ExportUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportExport(org.Name, proj.Name, req.Report, tokens[recipient]),
EditUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportEdit(org.Name, proj.Name, req.Report),
UserId: req.OwnerId,
UserAttrs: ownerAttrs,
}
}
continue
}
if webOpenMode == WebOpenModeCreator {
urls[recipient] = &adminv1.GetReportMetaResponse_URLs{
delivery[recipient] = &adminv1.GetReportMetaResponse_DeliveryMeta{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, tokens[recipient], req.ExecutionTime.AsTime()),
ExportUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportExport(org.Name, proj.Name, req.Report, tokens[recipient]),
UnsubscribeUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportUnsubscribe(org.Name, proj.Name, req.Report, tokens[recipient], recipient),
UserId: req.OwnerId,
UserAttrs: ownerAttrs,
}
} else if webOpenMode == WebOpenModeRecipient {
urls[recipient] = &adminv1.GetReportMetaResponse_URLs{
attr, userID, err := s.getAttributesForProjectMember(ctx, recipient, proj.OrganizationID, proj.ID)
if err != nil {
return nil, err
}
pbAttrs, err := structpb.NewStruct(attr)
if err != nil {
return nil, err
}
delivery[recipient] = &adminv1.GetReportMetaResponse_DeliveryMeta{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, "", req.ExecutionTime.AsTime()),
ExportUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportExport(org.Name, proj.Name, req.Report, ""),
UnsubscribeUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportUnsubscribe(org.Name, proj.Name, req.Report, tokens[recipient], recipient), // still use token for unsubscribe so that it works seamlessly for non Rill users
UserId: userID,
UserAttrs: pbAttrs,
}
} else {
// same as recipient but no open url
urls[recipient] = &adminv1.GetReportMetaResponse_URLs{
} else { // same as recipient but no open url
attr, userID, err := s.getAttributesForProjectMember(ctx, recipient, proj.OrganizationID, proj.ID)
if err != nil {
return nil, err
}
pbAttrs, err := structpb.NewStruct(attr)
if err != nil {
return nil, err
}
delivery[recipient] = &adminv1.GetReportMetaResponse_DeliveryMeta{
ExportUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportExport(org.Name, proj.Name, req.Report, ""),
UnsubscribeUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportUnsubscribe(org.Name, proj.Name, req.Report, tokens[recipient], recipient), // still use token for unsubscribe so that it works seamlessly for non Rill users
UserId: userID,
UserAttrs: pbAttrs,
}
}
}

return &adminv1.GetReportMetaResponse{
RecipientUrls: urls,
DeliveryMeta: delivery,
}, nil
}

Expand Down Expand Up @@ -515,11 +553,18 @@ func (s *Server) yamlForManagedReport(opts *adminv1.ReportOptions, ownerUserID s
res.Refresh.TimeZone = opts.RefreshTimeZone
res.Watermark = "inherit"
res.Intervals.Duration = opts.IntervalDuration
res.Query.Name = opts.QueryName
res.Query.ArgsJSON = opts.QueryArgsJson

if opts.Resolver != "" {
res.Data = map[string]any{
opts.Resolver: opts.ResolverProperties,
}
}
res.Query.Name = opts.QueryName // nolint:staticcheck // backwards compatibility
res.Query.ArgsJSON = opts.QueryArgsJson // nolint:staticcheck // backwards compatibility
res.Export.Format = opts.ExportFormat.String()
res.Export.IncludeHeader = opts.ExportIncludeHeader
res.Export.Limit = uint(opts.ExportLimit)

res.Notify.Email.Recipients = opts.EmailRecipients
res.Notify.Slack.Channels = opts.SlackChannels
res.Notify.Slack.Users = opts.SlackUsers
Expand Down Expand Up @@ -547,8 +592,8 @@ func (s *Server) yamlForManagedReport(opts *adminv1.ReportOptions, ownerUserID s
func (s *Server) yamlForCommittedReport(opts *adminv1.ReportOptions) ([]byte, error) {
// Format args as pretty YAML
var args map[string]interface{}
if opts.QueryArgsJson != "" {
err := json.Unmarshal([]byte(opts.QueryArgsJson), &args)
if opts.QueryArgsJson != "" { // nolint:staticcheck // backwards compatibility
err := json.Unmarshal([]byte(opts.QueryArgsJson), &args) // nolint:staticcheck // backwards compatibility
if err != nil {
return nil, fmt.Errorf("failed to parse queryArgsJSON: %w", err)
}
Expand All @@ -574,7 +619,12 @@ func (s *Server) yamlForCommittedReport(opts *adminv1.ReportOptions) ([]byte, er
res.Refresh.TimeZone = opts.RefreshTimeZone
res.Watermark = "inherit"
res.Intervals.Duration = opts.IntervalDuration
res.Query.Name = opts.QueryName
if opts.Resolver != "" {
res.Data = map[string]any{
opts.Resolver: opts.ResolverProperties,
}
}
res.Query.Name = opts.QueryName // nolint:staticcheck // backwards compatibility
res.Query.Args = args
res.Export.Format = exportFormat
res.Export.IncludeHeader = opts.ExportIncludeHeader
Expand Down Expand Up @@ -768,6 +818,30 @@ func (s *Server) createUnsubMagicTokens(ctx context.Context, projectID, reportNa
return emailTokens, nil
}

// getAttributesForProjectMember returns user attributes for a given email only if the user is a member of the project.
func (s *Server) getAttributesForProjectMember(ctx context.Context, email, orgID, projID string) (map[string]any, string, error) {
if email == "" {
return nil, "", nil
}
// Look up user by email
user, err := s.admin.DB.FindUserByEmail(ctx, email)
if err != nil && !errors.Is(err, database.ErrNotFound) {
return nil, "", err
}
if user == nil {
return nil, "", nil
}

attr, id, readProd, err := s.getAttributesForUser(ctx, orgID, projID, user.ID, "")
if err != nil {
return nil, "", err
}
if !readProd {
return nil, "", nil
}
return attr, id, nil
}

var reportNameToDashCharsRegexp = regexp.MustCompile(`[ _]+`)

var reportNameExcludeCharsRegexp = regexp.MustCompile(`[^a-zA-Z0-9-]+`)
Expand Down Expand Up @@ -798,11 +872,12 @@ type reportYAML struct {
Intervals struct {
Duration string `yaml:"duration"`
} `yaml:"intervals"`
Query struct {
Name string `yaml:"name"`
Data map[string]any `yaml:"data,omitempty"` // Generic data resolver block (e.g., data.ai, data.sql)
Query struct { // Legacy query-based report (deprecated - use data instead)
Name string `yaml:"name,omitempty"`
Args map[string]any `yaml:"args,omitempty"`
ArgsJSON string `yaml:"args_json,omitempty"`
} `yaml:"query"`
} `yaml:"query,omitempty"`
Export struct {
Format string `yaml:"format"`
IncludeHeader bool `yaml:"include_header"`
Expand Down
Loading
Loading