diff --git a/cmd/internal/openapi/client.gen.go b/cmd/internal/openapi/client.gen.go index 6288857d..6746a136 100644 --- a/cmd/internal/openapi/client.gen.go +++ b/cmd/internal/openapi/client.gen.go @@ -18,6 +18,54 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +// Defines values for ActivityEventActorType. +const ( + ActivityEventActorTypeAnonymous ActivityEventActorType = "anonymous" + ActivityEventActorTypeDownloader ActivityEventActorType = "downloader" + ActivityEventActorTypeSystem ActivityEventActorType = "system" + ActivityEventActorTypeUser ActivityEventActorType = "user" +) + +// Valid indicates whether the value is a known member of the ActivityEventActorType enum. +func (e ActivityEventActorType) Valid() bool { + switch e { + case ActivityEventActorTypeAnonymous: + return true + case ActivityEventActorTypeDownloader: + return true + case ActivityEventActorTypeSystem: + return true + case ActivityEventActorTypeUser: + return true + default: + return false + } +} + +// Defines values for AuditEventActorType. +const ( + AuditEventActorTypeAnonymous AuditEventActorType = "anonymous" + AuditEventActorTypeDownloader AuditEventActorType = "downloader" + AuditEventActorTypeSystem AuditEventActorType = "system" + AuditEventActorTypeUser AuditEventActorType = "user" +) + +// Valid indicates whether the value is a known member of the AuditEventActorType enum. +func (e AuditEventActorType) Valid() bool { + switch e { + case AuditEventActorTypeAnonymous: + return true + case AuditEventActorTypeDownloader: + return true + case AuditEventActorTypeSystem: + return true + case AuditEventActorTypeUser: + return true + default: + return false + } +} + // Defines values for DownloadTaskSpecSourceType. const ( DownloadTaskSpecSourceTypeHttp DownloadTaskSpecSourceType = "http" @@ -1277,22 +1325,27 @@ func (e GrantUserEntitlementJSONBodyResourceType) Valid() bool { // ActivityEvent defines model for ActivityEvent. type ActivityEvent struct { - Action string `json:"action"` - CreatedAt string `json:"createdAt"` - Id string `json:"id"` - Metadata *string `json:"metadata"` - OrgId string `json:"orgId"` - TargetId *string `json:"targetId"` - TargetName string `json:"targetName"` - TargetType string `json:"targetType"` + Action string `json:"action"` + ActorRef *string `json:"actorRef"` + ActorType ActivityEventActorType `json:"actorType"` + CreatedAt string `json:"createdAt"` + Id string `json:"id"` + Metadata *string `json:"metadata"` + OrgId string `json:"orgId"` + TargetId *string `json:"targetId"` + TargetName string `json:"targetName"` + TargetType string `json:"targetType"` User struct { - Id string `json:"id"` + Id *string `json:"id"` Image *string `json:"image"` Name string `json:"name"` } `json:"user"` - UserId string `json:"userId"` + UserId *string `json:"userId"` } +// ActivityEventActorType defines model for ActivityEvent.ActorType. +type ActivityEventActorType string + // ActivityPage defines model for ActivityPage. type ActivityPage struct { Items []ActivityEvent `json:"items"` @@ -1344,6 +1397,9 @@ type AdminCoreStats struct { } `json:"users"` } +// AdminDashboardStats defines model for AdminDashboardStats. +type AdminDashboardStats = interface{} + // AdminDetailedStats defines model for AdminDetailedStats. type AdminDetailedStats struct { GeneratedAt string `json:"generatedAt"` @@ -1469,23 +1525,28 @@ type AnnouncementList struct { // AuditEvent defines model for AuditEvent. type AuditEvent struct { - Action string `json:"action"` - CreatedAt string `json:"createdAt"` - Id string `json:"id"` - Metadata *string `json:"metadata"` - OrgId string `json:"orgId"` - OrgName *string `json:"orgName"` - TargetId *string `json:"targetId"` - TargetName string `json:"targetName"` - TargetType string `json:"targetType"` + Action string `json:"action"` + ActorRef *string `json:"actorRef"` + ActorType AuditEventActorType `json:"actorType"` + CreatedAt string `json:"createdAt"` + Id string `json:"id"` + Metadata *string `json:"metadata"` + OrgId string `json:"orgId"` + OrgName *string `json:"orgName"` + TargetId *string `json:"targetId"` + TargetName string `json:"targetName"` + TargetType string `json:"targetType"` User struct { - Id string `json:"id"` + Id *string `json:"id"` Image *string `json:"image"` Name string `json:"name"` } `json:"user"` - UserId string `json:"userId"` + UserId *string `json:"userId"` } +// AuditEventActorType defines model for AuditEvent.ActorType. +type AuditEventActorType string + // AuditEventPage defines model for AuditEventPage. type AuditEventPage struct { Items []AuditEvent `json:"items"` @@ -2451,6 +2512,42 @@ type GetAdminDetailedStatsParams struct { PeriodDays *int `form:"periodDays,omitempty" json:"periodDays,omitempty"` } +// GetAdminDashboardGrowthStatsParams defines parameters for GetAdminDashboardGrowthStats. +type GetAdminDashboardGrowthStatsParams struct { + From *time.Time `form:"from,omitempty" json:"from,omitempty"` + To *time.Time `form:"to,omitempty" json:"to,omitempty"` +} + +// GetAdminDashboardOverviewStatsParams defines parameters for GetAdminDashboardOverviewStats. +type GetAdminDashboardOverviewStatsParams struct { + From *time.Time `form:"from,omitempty" json:"from,omitempty"` + To *time.Time `form:"to,omitempty" json:"to,omitempty"` +} + +// GetAdminDashboardRankingStatsParams defines parameters for GetAdminDashboardRankingStats. +type GetAdminDashboardRankingStatsParams struct { + From *time.Time `form:"from,omitempty" json:"from,omitempty"` + To *time.Time `form:"to,omitempty" json:"to,omitempty"` +} + +// GetAdminDashboardSharingStatsParams defines parameters for GetAdminDashboardSharingStats. +type GetAdminDashboardSharingStatsParams struct { + From *time.Time `form:"from,omitempty" json:"from,omitempty"` + To *time.Time `form:"to,omitempty" json:"to,omitempty"` +} + +// GetAdminDashboardStorageStatsParams defines parameters for GetAdminDashboardStorageStats. +type GetAdminDashboardStorageStatsParams struct { + From *time.Time `form:"from,omitempty" json:"from,omitempty"` + To *time.Time `form:"to,omitempty" json:"to,omitempty"` +} + +// GetAdminDashboardTrafficStatsParams defines parameters for GetAdminDashboardTrafficStats. +type GetAdminDashboardTrafficStatsParams struct { + From *time.Time `form:"from,omitempty" json:"from,omitempty"` + To *time.Time `form:"to,omitempty" json:"to,omitempty"` +} + // BanUserJSONBody defines parameters for BanUser. type BanUserJSONBody struct { // BanExpiresIn The number of seconds until the ban expires @@ -4439,6 +4536,24 @@ type ClientInterface interface { // GetAdminDetailedStats request GetAdminDetailedStats(ctx context.Context, params *GetAdminDetailedStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetAdminDashboardGrowthStats request + GetAdminDashboardGrowthStats(ctx context.Context, params *GetAdminDashboardGrowthStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetAdminDashboardOverviewStats request + GetAdminDashboardOverviewStats(ctx context.Context, params *GetAdminDashboardOverviewStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetAdminDashboardRankingStats request + GetAdminDashboardRankingStats(ctx context.Context, params *GetAdminDashboardRankingStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetAdminDashboardSharingStats request + GetAdminDashboardSharingStats(ctx context.Context, params *GetAdminDashboardSharingStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetAdminDashboardStorageStats request + GetAdminDashboardStorageStats(ctx context.Context, params *GetAdminDashboardStorageStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetAdminDashboardTrafficStats request + GetAdminDashboardTrafficStats(ctx context.Context, params *GetAdminDashboardTrafficStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetApiAuthAccountInfo request GetApiAuthAccountInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -5309,6 +5424,78 @@ func (c *Client) GetAdminDetailedStats(ctx context.Context, params *GetAdminDeta return c.Client.Do(req) } +func (c *Client) GetAdminDashboardGrowthStats(ctx context.Context, params *GetAdminDashboardGrowthStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAdminDashboardGrowthStatsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetAdminDashboardOverviewStats(ctx context.Context, params *GetAdminDashboardOverviewStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAdminDashboardOverviewStatsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetAdminDashboardRankingStats(ctx context.Context, params *GetAdminDashboardRankingStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAdminDashboardRankingStatsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetAdminDashboardSharingStats(ctx context.Context, params *GetAdminDashboardSharingStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAdminDashboardSharingStatsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetAdminDashboardStorageStats(ctx context.Context, params *GetAdminDashboardStorageStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAdminDashboardStorageStatsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetAdminDashboardTrafficStats(ctx context.Context, params *GetAdminDashboardTrafficStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAdminDashboardTrafficStatsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetApiAuthAccountInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetApiAuthAccountInfoRequest(c.Server) if err != nil { @@ -9182,6 +9369,402 @@ func NewGetAdminDetailedStatsRequest(server string, params *GetAdminDetailedStat return req, nil } +// NewGetAdminDashboardGrowthStatsRequest generates requests for GetAdminDashboardGrowthStats +func NewGetAdminDashboardGrowthStatsRequest(server string, params *GetAdminDashboardGrowthStatsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/admin/stats/growth") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.From != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "from", *params.From, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.To != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "to", *params.To, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetAdminDashboardOverviewStatsRequest generates requests for GetAdminDashboardOverviewStats +func NewGetAdminDashboardOverviewStatsRequest(server string, params *GetAdminDashboardOverviewStatsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/admin/stats/overview") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.From != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "from", *params.From, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.To != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "to", *params.To, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetAdminDashboardRankingStatsRequest generates requests for GetAdminDashboardRankingStats +func NewGetAdminDashboardRankingStatsRequest(server string, params *GetAdminDashboardRankingStatsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/admin/stats/ranking") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.From != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "from", *params.From, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.To != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "to", *params.To, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetAdminDashboardSharingStatsRequest generates requests for GetAdminDashboardSharingStats +func NewGetAdminDashboardSharingStatsRequest(server string, params *GetAdminDashboardSharingStatsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/admin/stats/sharing") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.From != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "from", *params.From, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.To != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "to", *params.To, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetAdminDashboardStorageStatsRequest generates requests for GetAdminDashboardStorageStats +func NewGetAdminDashboardStorageStatsRequest(server string, params *GetAdminDashboardStorageStatsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/admin/stats/storage") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.From != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "from", *params.From, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.To != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "to", *params.To, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetAdminDashboardTrafficStatsRequest generates requests for GetAdminDashboardTrafficStats +func NewGetAdminDashboardTrafficStatsRequest(server string, params *GetAdminDashboardTrafficStatsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/admin/stats/traffic") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.From != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "from", *params.From, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.To != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "to", *params.To, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetApiAuthAccountInfoRequest generates requests for GetApiAuthAccountInfo func NewGetApiAuthAccountInfoRequest(server string) (*http.Request, error) { var err error @@ -18321,6 +18904,24 @@ type ClientWithResponsesInterface interface { // GetAdminDetailedStatsWithResponse request GetAdminDetailedStatsWithResponse(ctx context.Context, params *GetAdminDetailedStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDetailedStatsResponse, error) + // GetAdminDashboardGrowthStatsWithResponse request + GetAdminDashboardGrowthStatsWithResponse(ctx context.Context, params *GetAdminDashboardGrowthStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardGrowthStatsResponse, error) + + // GetAdminDashboardOverviewStatsWithResponse request + GetAdminDashboardOverviewStatsWithResponse(ctx context.Context, params *GetAdminDashboardOverviewStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardOverviewStatsResponse, error) + + // GetAdminDashboardRankingStatsWithResponse request + GetAdminDashboardRankingStatsWithResponse(ctx context.Context, params *GetAdminDashboardRankingStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardRankingStatsResponse, error) + + // GetAdminDashboardSharingStatsWithResponse request + GetAdminDashboardSharingStatsWithResponse(ctx context.Context, params *GetAdminDashboardSharingStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardSharingStatsResponse, error) + + // GetAdminDashboardStorageStatsWithResponse request + GetAdminDashboardStorageStatsWithResponse(ctx context.Context, params *GetAdminDashboardStorageStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardStorageStatsResponse, error) + + // GetAdminDashboardTrafficStatsWithResponse request + GetAdminDashboardTrafficStatsWithResponse(ctx context.Context, params *GetAdminDashboardTrafficStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardTrafficStatsResponse, error) + // GetApiAuthAccountInfoWithResponse request GetApiAuthAccountInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiAuthAccountInfoResponse, error) @@ -19229,6 +19830,193 @@ func (r GetAdminDetailedStatsResponse) ContentType() string { return "" } +type GetAdminDashboardGrowthStatsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AdminDashboardStats + JSON402 *Error +} + +// Status returns HTTPResponse.Status +func (r GetAdminDashboardGrowthStatsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAdminDashboardGrowthStatsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetAdminDashboardGrowthStatsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetAdminDashboardOverviewStatsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AdminDashboardStats + JSON400 *Error + JSON401 *Error +} + +// Status returns HTTPResponse.Status +func (r GetAdminDashboardOverviewStatsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAdminDashboardOverviewStatsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetAdminDashboardOverviewStatsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetAdminDashboardRankingStatsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AdminDashboardStats + JSON402 *Error +} + +// Status returns HTTPResponse.Status +func (r GetAdminDashboardRankingStatsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAdminDashboardRankingStatsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetAdminDashboardRankingStatsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetAdminDashboardSharingStatsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AdminDashboardStats + JSON402 *Error +} + +// Status returns HTTPResponse.Status +func (r GetAdminDashboardSharingStatsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAdminDashboardSharingStatsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetAdminDashboardSharingStatsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetAdminDashboardStorageStatsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AdminDashboardStats + JSON402 *Error +} + +// Status returns HTTPResponse.Status +func (r GetAdminDashboardStorageStatsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAdminDashboardStorageStatsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetAdminDashboardStorageStatsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetAdminDashboardTrafficStatsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AdminDashboardStats + JSON402 *Error +} + +// Status returns HTTPResponse.Status +func (r GetAdminDashboardTrafficStatsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAdminDashboardTrafficStatsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetAdminDashboardTrafficStatsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type GetApiAuthAccountInfoResponse struct { Body []byte HTTPResponse *http.Response @@ -27982,6 +28770,60 @@ func (c *ClientWithResponses) GetAdminDetailedStatsWithResponse(ctx context.Cont return ParseGetAdminDetailedStatsResponse(rsp) } +// GetAdminDashboardGrowthStatsWithResponse request returning *GetAdminDashboardGrowthStatsResponse +func (c *ClientWithResponses) GetAdminDashboardGrowthStatsWithResponse(ctx context.Context, params *GetAdminDashboardGrowthStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardGrowthStatsResponse, error) { + rsp, err := c.GetAdminDashboardGrowthStats(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAdminDashboardGrowthStatsResponse(rsp) +} + +// GetAdminDashboardOverviewStatsWithResponse request returning *GetAdminDashboardOverviewStatsResponse +func (c *ClientWithResponses) GetAdminDashboardOverviewStatsWithResponse(ctx context.Context, params *GetAdminDashboardOverviewStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardOverviewStatsResponse, error) { + rsp, err := c.GetAdminDashboardOverviewStats(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAdminDashboardOverviewStatsResponse(rsp) +} + +// GetAdminDashboardRankingStatsWithResponse request returning *GetAdminDashboardRankingStatsResponse +func (c *ClientWithResponses) GetAdminDashboardRankingStatsWithResponse(ctx context.Context, params *GetAdminDashboardRankingStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardRankingStatsResponse, error) { + rsp, err := c.GetAdminDashboardRankingStats(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAdminDashboardRankingStatsResponse(rsp) +} + +// GetAdminDashboardSharingStatsWithResponse request returning *GetAdminDashboardSharingStatsResponse +func (c *ClientWithResponses) GetAdminDashboardSharingStatsWithResponse(ctx context.Context, params *GetAdminDashboardSharingStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardSharingStatsResponse, error) { + rsp, err := c.GetAdminDashboardSharingStats(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAdminDashboardSharingStatsResponse(rsp) +} + +// GetAdminDashboardStorageStatsWithResponse request returning *GetAdminDashboardStorageStatsResponse +func (c *ClientWithResponses) GetAdminDashboardStorageStatsWithResponse(ctx context.Context, params *GetAdminDashboardStorageStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardStorageStatsResponse, error) { + rsp, err := c.GetAdminDashboardStorageStats(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAdminDashboardStorageStatsResponse(rsp) +} + +// GetAdminDashboardTrafficStatsWithResponse request returning *GetAdminDashboardTrafficStatsResponse +func (c *ClientWithResponses) GetAdminDashboardTrafficStatsWithResponse(ctx context.Context, params *GetAdminDashboardTrafficStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDashboardTrafficStatsResponse, error) { + rsp, err := c.GetAdminDashboardTrafficStats(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAdminDashboardTrafficStatsResponse(rsp) +} + // GetApiAuthAccountInfoWithResponse request returning *GetApiAuthAccountInfoResponse func (c *ClientWithResponses) GetApiAuthAccountInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiAuthAccountInfoResponse, error) { rsp, err := c.GetApiAuthAccountInfo(ctx, reqEditors...) @@ -30789,6 +31631,211 @@ func ParseGetAdminDetailedStatsResponse(rsp *http.Response) (*GetAdminDetailedSt return response, nil } +// ParseGetAdminDashboardGrowthStatsResponse parses an HTTP response from a GetAdminDashboardGrowthStatsWithResponse call +func ParseGetAdminDashboardGrowthStatsResponse(rsp *http.Response) (*GetAdminDashboardGrowthStatsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAdminDashboardGrowthStatsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AdminDashboardStats + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON402 = &dest + + } + + return response, nil +} + +// ParseGetAdminDashboardOverviewStatsResponse parses an HTTP response from a GetAdminDashboardOverviewStatsWithResponse call +func ParseGetAdminDashboardOverviewStatsResponse(rsp *http.Response) (*GetAdminDashboardOverviewStatsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAdminDashboardOverviewStatsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AdminDashboardStats + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + } + + return response, nil +} + +// ParseGetAdminDashboardRankingStatsResponse parses an HTTP response from a GetAdminDashboardRankingStatsWithResponse call +func ParseGetAdminDashboardRankingStatsResponse(rsp *http.Response) (*GetAdminDashboardRankingStatsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAdminDashboardRankingStatsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AdminDashboardStats + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON402 = &dest + + } + + return response, nil +} + +// ParseGetAdminDashboardSharingStatsResponse parses an HTTP response from a GetAdminDashboardSharingStatsWithResponse call +func ParseGetAdminDashboardSharingStatsResponse(rsp *http.Response) (*GetAdminDashboardSharingStatsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAdminDashboardSharingStatsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AdminDashboardStats + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON402 = &dest + + } + + return response, nil +} + +// ParseGetAdminDashboardStorageStatsResponse parses an HTTP response from a GetAdminDashboardStorageStatsWithResponse call +func ParseGetAdminDashboardStorageStatsResponse(rsp *http.Response) (*GetAdminDashboardStorageStatsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAdminDashboardStorageStatsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AdminDashboardStats + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON402 = &dest + + } + + return response, nil +} + +// ParseGetAdminDashboardTrafficStatsResponse parses an HTTP response from a GetAdminDashboardTrafficStatsWithResponse call +func ParseGetAdminDashboardTrafficStatsResponse(rsp *http.Response) (*GetAdminDashboardTrafficStatsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAdminDashboardTrafficStatsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AdminDashboardStats + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON402 = &dest + + } + + return response, nil +} + // ParseGetApiAuthAccountInfoResponse parses an HTTP response from a GetApiAuthAccountInfoWithResponse call func ParseGetApiAuthAccountInfoResponse(rsp *http.Response) (*GetApiAuthAccountInfoResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/migrations/0054_flippant_lila_cheney.sql b/migrations/0054_flippant_lila_cheney.sql new file mode 100644 index 00000000..2d2e7106 --- /dev/null +++ b/migrations/0054_flippant_lila_cheney.sql @@ -0,0 +1,62 @@ +CREATE TABLE `stats_rollup_state` ( + `job_name` text PRIMARY KEY NOT NULL, + `cursor_created_at` integer, + `cursor_id` text, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `stats_rollups_daily` ( + `id` text PRIMARY KEY NOT NULL, + `bucket_start` integer NOT NULL, + `org_id` text DEFAULT '' NOT NULL, + `metric_key` text NOT NULL, + `dimension_key` text DEFAULT '' NOT NULL, + `dimension_value` text DEFAULT '' NOT NULL, + `count` integer DEFAULT 0 NOT NULL, + `bytes` integer DEFAULT 0 NOT NULL, + `unique_count` integer DEFAULT 0 NOT NULL, + `metadata` text, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `stats_rollups_daily_bucket_metric_dim_uniq` ON `stats_rollups_daily` (`bucket_start`,`org_id`,`metric_key`,`dimension_key`,`dimension_value`);--> statement-breakpoint +CREATE INDEX `stats_rollups_daily_metric_bucket_idx` ON `stats_rollups_daily` (`metric_key`,`bucket_start`);--> statement-breakpoint +CREATE TABLE `stats_rollups_hourly` ( + `id` text PRIMARY KEY NOT NULL, + `bucket_start` integer NOT NULL, + `org_id` text DEFAULT '' NOT NULL, + `metric_key` text NOT NULL, + `dimension_key` text DEFAULT '' NOT NULL, + `dimension_value` text DEFAULT '' NOT NULL, + `count` integer DEFAULT 0 NOT NULL, + `bytes` integer DEFAULT 0 NOT NULL, + `unique_count` integer DEFAULT 0 NOT NULL, + `metadata` text, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `stats_rollups_hourly_bucket_metric_dim_uniq` ON `stats_rollups_hourly` (`bucket_start`,`org_id`,`metric_key`,`dimension_key`,`dimension_value`);--> statement-breakpoint +CREATE INDEX `stats_rollups_hourly_metric_bucket_idx` ON `stats_rollups_hourly` (`metric_key`,`bucket_start`);--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_activity_events` ( + `id` text PRIMARY KEY NOT NULL, + `org_id` text NOT NULL, + `user_id` text, + `actor_type` text DEFAULT 'user' NOT NULL, + `actor_ref` text, + `action` text NOT NULL, + `target_type` text NOT NULL, + `target_id` text, + `target_name` text NOT NULL, + `metadata` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_activity_events`("id", "org_id", "user_id", "actor_type", "actor_ref", "action", "target_type", "target_id", "target_name", "metadata", "created_at") SELECT "id", "org_id", "user_id", 'user', NULL, "action", "target_type", "target_id", "target_name", "metadata", "created_at" FROM `activity_events`;--> statement-breakpoint +DROP TABLE `activity_events`;--> statement-breakpoint +ALTER TABLE `__new_activity_events` RENAME TO `activity_events`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `activity_events_org_created_idx` ON `activity_events` (`org_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `activity_events_user_created_idx` ON `activity_events` (`user_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `activity_events_action_created_idx` ON `activity_events` (`action`,`created_at`);--> statement-breakpoint +CREATE INDEX `activity_events_target_created_idx` ON `activity_events` (`target_type`,`target_id`,`created_at`); diff --git a/migrations/meta/0054_snapshot.json b/migrations/meta/0054_snapshot.json new file mode 100644 index 00000000..4676bc94 --- /dev/null +++ b/migrations/meta/0054_snapshot.json @@ -0,0 +1,4280 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "827fe3c3-39c0-4dd3-8b2b-3d1a9f148adc", + "prevId": "788eaeff-a3a7-4da9-aadb-ab59817b494f", + "tables": { + "activity_events": { + "name": "activity_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "actor_ref": { + "name": "actor_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_name": { + "name": "target_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "activity_events_org_created_idx": { + "name": "activity_events_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "activity_events_user_created_idx": { + "name": "activity_events_user_created_idx", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "activity_events_action_created_idx": { + "name": "activity_events_action_created_idx", + "columns": [ + "action", + "created_at" + ], + "isUnique": false + }, + "activity_events_target_created_idx": { + "name": "activity_events_target_created_idx", + "columns": [ + "target_type", + "target_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "announcements": { + "name": "announcements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "announcements_status_priority_idx": { + "name": "announcements_status_priority_idx", + "columns": [ + "status", + "priority" + ], + "isUnique": false + }, + "announcements_published_idx": { + "name": "announcements_published_idx", + "columns": [ + "published_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "background_jobs": { + "name": "background_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_folder": { + "name": "target_folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_path": { + "name": "target_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_bytes": { + "name": "input_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_bytes": { + "name": "output_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processed_bytes": { + "name": "processed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_filename": { + "name": "current_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_metadata": { + "name": "result_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retryable": { + "name": "retryable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cancelable": { + "name": "cancelable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "retried_from_job_id": { + "name": "retried_from_job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "background_jobs_org_created_idx": { + "name": "background_jobs_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "background_jobs_org_status_idx": { + "name": "background_jobs_org_status_idx", + "columns": [ + "org_id", + "status" + ], + "isUnique": false + }, + "background_jobs_org_type_idx": { + "name": "background_jobs_org_type_idx", + "columns": [ + "org_id", + "type" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cloud_traffic_reports": { + "name": "cloud_traffic_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_bytes": { + "name": "unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credits_per_unit": { + "name": "credits_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "cloud_traffic_reports_event_uniq": { + "name": "cloud_traffic_reports_event_uniq", + "columns": [ + "event_id" + ], + "isUnique": true + }, + "cloud_traffic_reports_org_period_idx": { + "name": "cloud_traffic_reports_org_period_idx", + "columns": [ + "org_id", + "period" + ], + "isUnique": false + }, + "cloud_traffic_reports_status_idx": { + "name": "cloud_traffic_reports_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "download_tasks": { + "name": "download_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_uri": { + "name": "source_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_folder": { + "name": "target_folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "assigned_downloader_id": { + "name": "assigned_downloader_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "billing_authorized_bytes": { + "name": "billing_authorized_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billing_charged_bytes": { + "name": "billing_charged_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billing_charged_credits": { + "name": "billing_charged_credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_object_id": { + "name": "result_object_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runtime": { + "name": "runtime", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolve_started_at": { + "name": "resolve_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolve_completed_at": { + "name": "resolve_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_completed_at": { + "name": "download_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ingest_started_at": { + "name": "ingest_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ingest_completed_at": { + "name": "ingest_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seeding_started_at": { + "name": "seeding_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seeding_stopped_at": { + "name": "seeding_stopped_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "download_tasks_org_created_idx": { + "name": "download_tasks_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "download_tasks_org_status_idx": { + "name": "download_tasks_org_status_idx", + "columns": [ + "org_id", + "status" + ], + "isUnique": false + }, + "download_tasks_org_category_idx": { + "name": "download_tasks_org_category_idx", + "columns": [ + "org_id", + "category" + ], + "isUnique": false + }, + "download_tasks_org_tags_idx": { + "name": "download_tasks_org_tags_idx", + "columns": [ + "org_id", + "tags" + ], + "isUnique": false + }, + "download_tasks_downloader_idx": { + "name": "download_tasks_downloader_idx", + "columns": [ + "assigned_downloader_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloaders": { + "name": "downloaders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_jti": { + "name": "token_jti", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'offline'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "arch": { + "name": "arch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "engine": { + "name": "engine", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'http'" + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "max_concurrent_tasks": { + "name": "max_concurrent_tasks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "current_tasks": { + "name": "current_tasks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "download_bps": { + "name": "download_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upload_bps": { + "name": "upload_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "free_disk_bytes": { + "name": "free_disk_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remote_download_credit_billing_enabled": { + "name": "remote_download_credit_billing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_download_credit_unit_bytes": { + "name": "remote_download_credit_unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 104857600 + }, + "remote_download_credit_per_unit": { + "name": "remote_download_credit_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "downloaders_token_jti_unique": { + "name": "downloaders_token_jti_unique", + "columns": [ + "token_jti" + ], + "isUnique": true + }, + "downloaders_status_idx": { + "name": "downloaders_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "downloaders_enabled_idx": { + "name": "downloaders_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + }, + "downloaders_created_idx": { + "name": "downloaders_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image_hosting_configs": { + "name": "image_hosting_configs", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cf_hostname_id": { + "name": "cf_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain_verified_at": { + "name": "domain_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referer_allowlist": { + "name": "referer_allowlist", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "image_hosting_configs_custom_domain_unique": { + "name": "image_hosting_configs_custom_domain_unique", + "columns": [ + "custom_domain" + ], + "isUnique": true + } + }, + "foreignKeys": { + "image_hosting_configs_org_id_organization_id_fk": { + "name": "image_hosting_configs_org_id_organization_id_fk", + "tableFrom": "image_hosting_configs", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image_hostings": { + "name": "image_hostings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "image_hostings_token_unique": { + "name": "image_hostings_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "image_hostings_org_path_uniq": { + "name": "image_hostings_org_path_uniq", + "columns": [ + "org_id", + "path" + ], + "isUnique": true + }, + "image_hostings_org_created_idx": { + "name": "image_hostings_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "image_hostings_token_idx": { + "name": "image_hostings_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "image_hostings_org_id_organization_id_fk": { + "name": "image_hostings_org_id_organization_id_fk", + "tableFrom": "image_hostings", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "image_hostings_storage_id_storages_id_fk": { + "name": "image_hostings_storage_id_storages_id_fk", + "tableFrom": "image_hostings", + "tableTo": "storages", + "columnsFrom": [ + "storage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite_codes": { + "name": "invite_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_by": { + "name": "used_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_codes_code_unique": { + "name": "invite_codes_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "license_bindings": { + "name": "license_bindings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cloud_binding_id": { + "name": "cloud_binding_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_store_id": { + "name": "cloud_store_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_account_id": { + "name": "cloud_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_account_email": { + "name": "cloud_account_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cached_certificate": { + "name": "cached_certificate", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cached_certificate_expires_at": { + "name": "cached_certificate_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bound_at": { + "name": "bound_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refresh_at": { + "name": "last_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refresh_error": { + "name": "last_refresh_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "license_bindings_active_uniq": { + "name": "license_bindings_active_uniq", + "columns": [ + "status" + ], + "isUnique": true, + "where": "status = 'active'" + }, + "license_bindings_cloud_binding_idx": { + "name": "license_bindings_cloud_binding_idx", + "columns": [ + "cloud_binding_id" + ], + "isUnique": false + }, + "license_bindings_instance_idx": { + "name": "license_bindings_instance_idx", + "columns": [ + "instance_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "matters": { + "name": "matters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "dirtype": { + "name": "dirtype", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "parent": { + "name": "parent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "object": { + "name": "object", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "trashed_at": { + "name": "trashed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "matters_alias_unique": { + "name": "matters_alias_unique", + "columns": [ + "alias" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "notifications_user_read_idx": { + "name": "notifications_user_read_idx", + "columns": [ + "user_id", + "read_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "object_upload_sessions": { + "name": "object_upload_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_id": { + "name": "object_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upload_id": { + "name": "upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_conflict": { + "name": "on_conflict", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fail'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "object_upload_sessions_object_idx": { + "name": "object_upload_sessions_object_idx", + "columns": [ + "org_id", + "object_id" + ], + "isUnique": false + }, + "object_upload_sessions_expires_idx": { + "name": "object_upload_sessions_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "org_quota_entitlements": { + "name": "org_quota_entitlements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entitlement_type": { + "name": "entitlement_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'grant'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starts_at": { + "name": "starts_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "org_quota_entitlements_org_resource_idx": { + "name": "org_quota_entitlements_org_resource_idx", + "columns": [ + "org_id", + "resource_type", + "status" + ], + "isUnique": false + }, + "org_quota_entitlements_org_type_idx": { + "name": "org_quota_entitlements_org_type_idx", + "columns": [ + "org_id", + "resource_type", + "entitlement_type", + "status" + ], + "isUnique": false + }, + "org_quota_entitlements_active_plan_uniq": { + "name": "org_quota_entitlements_active_plan_uniq", + "columns": [ + "org_id", + "resource_type", + "entitlement_type" + ], + "isUnique": true, + "where": "status = 'active' AND entitlement_type = 'plan'" + }, + "org_quota_entitlements_source_resource_uniq": { + "name": "org_quota_entitlements_source_resource_uniq", + "columns": [ + "source", + "source_id", + "resource_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "org_quotas": { + "name": "org_quotas", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quota": { + "name": "quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_quota": { + "name": "traffic_quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_used": { + "name": "traffic_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_period": { + "name": "traffic_period", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "remote_download_usage_reports": { + "name": "remote_download_usage_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "downloader_id": { + "name": "downloader_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit_index": { + "name": "unit_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit_bytes": { + "name": "unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits_per_unit": { + "name": "credits_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "remote_download_usage_reports_event_id_unique": { + "name": "remote_download_usage_reports_event_id_unique", + "columns": [ + "event_id" + ], + "isUnique": true + }, + "remote_download_usage_task_unit_uniq": { + "name": "remote_download_usage_task_unit_uniq", + "columns": [ + "task_id", + "unit_index" + ], + "isUnique": true + }, + "remote_download_usage_org_idx": { + "name": "remote_download_usage_org_idx", + "columns": [ + "org_id" + ], + "isUnique": false + }, + "remote_download_usage_status_idx": { + "name": "remote_download_usage_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "share_recipients": { + "name": "share_recipients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "share_recipients_share_id_idx": { + "name": "share_recipients_share_id_idx", + "columns": [ + "share_id" + ], + "isUnique": false + }, + "share_recipients_user_id_idx": { + "name": "share_recipients_user_id_idx", + "columns": [ + "recipient_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shares": { + "name": "shares", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "matter_id": { + "name": "matter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_limit": { + "name": "download_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "downloads": { + "name": "downloads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "shares_token_unique": { + "name": "shares_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "shares_creator_status_created_idx": { + "name": "shares_creator_status_created_idx", + "columns": [ + "creator_id", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_invitations": { + "name": "site_invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "site_invitations_token_unique": { + "name": "site_invitations_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "site_invitations_email_idx": { + "name": "site_invitations_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "site_invitations_created_idx": { + "name": "site_invitations_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "site_invitations_expires_idx": { + "name": "site_invitations_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stats_rollup_state": { + "name": "stats_rollup_state", + "columns": { + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cursor_created_at": { + "name": "cursor_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor_id": { + "name": "cursor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stats_rollups_daily": { + "name": "stats_rollups_daily", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bucket_start": { + "name": "bucket_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "metric_key": { + "name": "metric_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dimension_key": { + "name": "dimension_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "dimension_value": { + "name": "dimension_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "unique_count": { + "name": "unique_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "stats_rollups_daily_bucket_metric_dim_uniq": { + "name": "stats_rollups_daily_bucket_metric_dim_uniq", + "columns": [ + "bucket_start", + "org_id", + "metric_key", + "dimension_key", + "dimension_value" + ], + "isUnique": true + }, + "stats_rollups_daily_metric_bucket_idx": { + "name": "stats_rollups_daily_metric_bucket_idx", + "columns": [ + "metric_key", + "bucket_start" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stats_rollups_hourly": { + "name": "stats_rollups_hourly", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bucket_start": { + "name": "bucket_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "metric_key": { + "name": "metric_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dimension_key": { + "name": "dimension_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "dimension_value": { + "name": "dimension_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "unique_count": { + "name": "unique_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "stats_rollups_hourly_bucket_metric_dim_uniq": { + "name": "stats_rollups_hourly_bucket_metric_dim_uniq", + "columns": [ + "bucket_start", + "org_id", + "metric_key", + "dimension_key", + "dimension_value" + ], + "isUnique": true + }, + "stats_rollups_hourly_metric_bucket_idx": { + "name": "stats_rollups_hourly_metric_bucket_idx", + "columns": [ + "metric_key", + "bucket_start" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storages": { + "name": "storages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "access_key": { + "name": "access_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_key": { + "name": "secret_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "custom_host": { + "name": "custom_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "egress_credit_billing_enabled": { + "name": "egress_credit_billing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "egress_credit_unit_bytes": { + "name": "egress_credit_unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 104857600 + }, + "egress_credit_per_unit": { + "name": "egress_credit_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "force_path_style": { + "name": "force_path_style", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_options": { + "name": "system_options", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_invite_links": { + "name": "team_invite_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "team_invite_links_token_unique": { + "name": "team_invite_links_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webdav_dead_properties": { + "name": "webdav_dead_properties", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_path": { + "name": "resource_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webdav_dead_properties_resource_prop_uniq": { + "name": "webdav_dead_properties_resource_prop_uniq", + "columns": [ + "org_id", + "resource_path", + "namespace", + "name" + ], + "isUnique": true + }, + "webdav_dead_properties_resource_idx": { + "name": "webdav_dead_properties_resource_idx", + "columns": [ + "org_id", + "resource_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webdav_locks": { + "name": "webdav_locks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_path": { + "name": "resource_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "depth": { + "name": "depth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'infinity'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webdav_locks_token_unique": { + "name": "webdav_locks_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "webdav_locks_resource_idx": { + "name": "webdav_locks_resource_idx", + "columns": [ + "org_id", + "resource_path" + ], + "isUnique": false + }, + "webdav_locks_expires_idx": { + "name": "webdav_locks_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_events": { + "name": "webhook_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cloud'" + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'order.quota_changed'" + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_events_source_event_uniq": { + "name": "webhook_events_source_event_uniq", + "columns": [ + "source", + "event_id" + ], + "isUnique": true + }, + "webhook_events_source_created_idx": { + "name": "webhook_events_source_created_idx", + "columns": [ + "source", + "created_at" + ], + "isUnique": false + }, + "webhook_events_status_idx": { + "name": "webhook_events_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "reference_id" + ], + "isUnique": false + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + "key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deviceCode": { + "name": "deviceCode", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "deviceCode_device_code_idx": { + "name": "deviceCode_device_code_idx", + "columns": [ + "device_code" + ], + "isUnique": false + }, + "deviceCode_user_code_idx": { + "name": "deviceCode_user_code_idx", + "columns": [ + "user_code" + ], + "isUnique": false + }, + "deviceCode_status_idx": { + "name": "deviceCode_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned": { + "name": "banned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 223e757b..8f87153e 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -372,6 +372,13 @@ "when": 1782781065387, "tag": "0053_striped_pride", "breakpoints": true + }, + { + "idx": 54, + "version": "6", + "when": 1783616186203, + "tag": "0054_flippant_lila_cheney", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index b96c3703..852bfbc4 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,9 @@ "better-sqlite3": "^12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "drizzle-orm": "^0.45.2", + "echarts": "^6.1.0", "fflate": "^0.8.2", "hono": "^4.12.25", "i18next": "^26.0.3", @@ -85,6 +87,7 @@ "posthog-node": "^5.36.4", "radix-ui": "^1.4.3", "react": "^19.2.5", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.5", "react-dropzone": "^15.0.0", "react-hook-form": "^7.54.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 868f954f..ac57e53d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,9 +92,15 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17) + echarts: + specifier: ^6.1.0 + version: 6.1.0 fflate: specifier: ^0.8.2 version: 0.8.2 @@ -134,6 +140,9 @@ importers: react: specifier: ^19.2.5 version: 19.2.5 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.14)(react@19.2.5) react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) @@ -815,6 +824,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -3800,6 +3812,9 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3959,6 +3974,9 @@ packages: sqlite3: optional: true + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + electron-to-chromium@1.5.364: resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} @@ -4951,6 +4969,16 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: @@ -5470,6 +5498,9 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -5856,6 +5887,9 @@ packages: hono: ^4.7.11 zod: ^4.3.6 + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6561,6 +6595,8 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@date-fns/tz@1.5.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.5)': dependencies: react: 19.2.5 @@ -9141,6 +9177,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + date-fns@4.4.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -9216,6 +9254,11 @@ snapshots: better-sqlite3: 12.10.0 kysely: 0.28.17 + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + electron-to-chromium@1.5.364: {} emoji-regex@10.6.0: {} @@ -10534,6 +10577,14 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-day-picker@10.0.1(@types/react@19.2.14)(react@19.2.5): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 @@ -11177,6 +11228,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@2.3.0: {} + tslib@2.8.1: {} tsup@8.5.1(@swc/core@1.15.40)(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0): @@ -11554,4 +11607,8 @@ snapshots: hono: 4.12.25 zod: 4.4.3 + zrender@6.1.0: + dependencies: + tslib: 2.3.0 + zwitch@2.0.4: {} diff --git a/server/adapters/repos/activity.ts b/server/adapters/repos/activity.ts index 1b998085..5b1d33c3 100644 --- a/server/adapters/repos/activity.ts +++ b/server/adapters/repos/activity.ts @@ -3,7 +3,19 @@ import { nanoid } from 'nanoid' import { organization, user } from '../../db/auth-schema' import { activityEvents } from '../../db/schema' import type { Database } from '../../platform/interface' -import type { ActivityRepo } from '../../usecases/ports' +import type { ActivityActorType, ActivityRepo } from '../../usecases/ports' + +function normalizeActorType(value: string): ActivityActorType { + if (value === 'anonymous' || value === 'system' || value === 'downloader') return value + return 'user' +} + +function actorDisplayName(actorType: string, actorRef: string | null): string { + if (actorType === 'anonymous') return 'Anonymous' + if (actorType === 'system') return actorRef ? `System:${actorRef}` : 'System' + if (actorType === 'downloader') return actorRef ? `Downloader:${actorRef}` : 'Downloader' + return '' +} export function createActivityRepo(db: Database): ActivityRepo { return { @@ -11,7 +23,9 @@ export function createActivityRepo(db: Database): ActivityRepo { await db.insert(activityEvents).values({ id: nanoid(), orgId: event.orgId, - userId: event.userId, + userId: event.userId ?? null, + actorType: event.actorType ?? (event.userId ? 'user' : 'anonymous'), + actorRef: event.actorRef ?? null, action: event.action, targetType: event.targetType, targetId: event.targetId ?? null, @@ -34,6 +48,8 @@ export function createActivityRepo(db: Database): ActivityRepo { id: activityEvents.id, orgId: activityEvents.orgId, userId: activityEvents.userId, + actorType: activityEvents.actorType, + actorRef: activityEvents.actorRef, action: activityEvents.action, targetType: activityEvents.targetType, targetId: activityEvents.targetId, @@ -54,13 +70,19 @@ export function createActivityRepo(db: Database): ActivityRepo { id: row.id, orgId: row.orgId, userId: row.userId, + actorType: normalizeActorType(row.actorType), + actorRef: row.actorRef, action: row.action, targetType: row.targetType, targetId: row.targetId, targetName: row.targetName, metadata: row.metadata, createdAt: row.createdAt, - user: { id: row.userId, name: row.userName ?? '', image: row.userImage ?? null }, + user: { + id: row.userId, + name: row.userName ?? actorDisplayName(row.actorType, row.actorRef), + image: row.userImage ?? null, + }, })) return { items, total } @@ -90,6 +112,8 @@ export function createActivityRepo(db: Database): ActivityRepo { id: activityEvents.id, orgId: activityEvents.orgId, userId: activityEvents.userId, + actorType: activityEvents.actorType, + actorRef: activityEvents.actorRef, action: activityEvents.action, targetType: activityEvents.targetType, targetId: activityEvents.targetId, @@ -112,13 +136,19 @@ export function createActivityRepo(db: Database): ActivityRepo { id: row.id, orgId: row.orgId, userId: row.userId, + actorType: normalizeActorType(row.actorType), + actorRef: row.actorRef, action: row.action, targetType: row.targetType, targetId: row.targetId, targetName: row.targetName, metadata: row.metadata, createdAt: row.createdAt, - user: { id: row.userId, name: row.userName ?? '', image: row.userImage ?? null }, + user: { + id: row.userId, + name: row.userName ?? actorDisplayName(row.actorType, row.actorRef), + image: row.userImage ?? null, + }, orgName: row.orgName ?? null, })) @@ -147,7 +177,10 @@ export function createActivityRepo(db: Database): ActivityRepo { ]) return { - items: rows, + items: rows.map((row) => ({ + ...row, + actorType: normalizeActorType(row.actorType), + })), total: countRows[0]?.count ?? 0, page, pageSize, diff --git a/server/adapters/repos/admin-stats.ts b/server/adapters/repos/admin-stats.ts index b2a36138..e569c4bf 100644 --- a/server/adapters/repos/admin-stats.ts +++ b/server/adapters/repos/admin-stats.ts @@ -1,9 +1,19 @@ import { isPersonalOrgLike } from '@shared/org-slugs' -import type { AdminStatsPoint } from '@shared/types' +import type { + AdminDashboardGrowthStats, + AdminDashboardOverviewStats, + AdminDashboardRankingStats, + AdminDashboardSharingStats, + AdminDashboardStorageStats, + AdminDashboardTrafficStats, + AdminStatsDelta, + AdminStatsPoint, + AdminTopShare, +} from '@shared/types' import type { SQL } from 'drizzle-orm' -import { and, count, desc, eq, gt, gte, inArray, isNull, sql } from 'drizzle-orm' +import { and, count, desc, eq, gt, gte, inArray, isNull, lte, sql } from 'drizzle-orm' import type { SQLiteTable } from 'drizzle-orm/sqlite-core' -import { organization, user } from '../../db/auth-schema' +import { account, organization, session, user } from '../../db/auth-schema' import { activityEvents, backgroundJobs, @@ -11,20 +21,34 @@ import { downloaders, downloadTasks, matters, + orgQuotas, shares, siteInvitations, storages, webhookEvents, } from '../../db/schema' import type { Database } from '../../platform/interface' -import type { AdminCoreStatsBase, AdminDetailedStatsBase, AdminStatsRepo } from '../../usecases/ports' +import type { + AdminCoreStatsBase, + AdminDetailedStatsBase, + AdminStatsDateRange, + AdminStatsRepo, +} from '../../usecases/ports' const RUNNING_DOWNLOAD_STATUSES = ['queued', 'assigned', 'running', 'downloading', 'ingesting'] +const DOWNLOAD_ACTIVITY_ACTIONS = ['share_download', 'object_download'] +const AUDITED_DOWNLOAD_SOURCES = new Set(['landing_share', 'object_download']) export function createAdminStatsRepo(db: Database): AdminStatsRepo { return { getCoreStatsBase: (now) => getCoreStatsBase(db, now), getDetailedStatsBase: (now, periodDays) => getDetailedStatsBase(db, now, periodDays), + getDashboardOverviewStats: (now, range) => getDashboardOverviewStats(db, now, range), + getDashboardGrowthStats: (now, range) => getDashboardGrowthStats(db, now, range), + getDashboardStorageStats: (now, range) => getDashboardStorageStats(db, now, range), + getDashboardTrafficStats: (now, range) => getDashboardTrafficStats(db, now, range), + getDashboardSharingStats: (now, range) => getDashboardSharingStats(db, now, range), + getDashboardRankingStats: (now, range) => getDashboardRankingStats(db, now, range), } } @@ -232,6 +256,7 @@ async function buildTrends(db: Database, start: Date, now: Date, periodDays: num const activeUsersByDay = new Map>() for (const row of activityRows) { + if (!row.userId) continue const key = dayKey(row.createdAt) if (!buckets.has(key)) continue const set = activeUsersByDay.get(key) ?? new Set() @@ -482,6 +507,802 @@ async function getCloudReportReliability(db: Database): Promise { + const previous = previousRange(range) + const [ + totalUsers, + newUsers, + previousNewUsers, + activeUsers, + previousActiveUsers, + quotas, + traffic, + previousTraffic, + sharing, + ] = await Promise.all([ + countRows(db, user), + countRowsWhere(db, user, and(gte(user.createdAt, range.from), lte(user.createdAt, range.to))), + countRowsWhere(db, user, and(gte(user.createdAt, previous.from), lte(user.createdAt, previous.to))), + countActiveUsers(db, range), + countActiveUsers(db, previous), + getQuotaTotals(db), + getTrafficTotals(db, range), + getTrafficTotals(db, previous), + getSharingEventTotals(db, range), + ]) + const [trendNewUsers, activeByDay, uploadByDay, downloadByDay] = await Promise.all([ + getNewUsersByDay(db, range), + getActiveUsersByDay(db, range), + getActivityBytesByDay(db, range, ['upload_confirm']), + getDownloadBytesByDay(db, range), + ]) + const storageBase = quotas.usedBytes - sumMap(uploadByDay) + let runningStorage = storageBase + const trends = createDateBuckets(range).map((date) => { + runningStorage += uploadByDay.get(date) ?? 0 + return { + date, + newUsers: trendNewUsers.get(date) ?? 0, + activeUsers: activeByDay.get(date)?.size ?? 0, + storageUsedBytes: Math.max(0, runningStorage), + uploadBytes: uploadByDay.get(date) ?? 0, + downloadBytes: downloadByDay.get(date) ?? 0, + } + }) + + return { + ...statsFrame(now, range), + totals: { + users: totalUsers, + newUsers: delta(newUsers, previousNewUsers), + activeUsers: delta(activeUsers, previousActiveUsers), + storageUsedBytes: quotas.usedBytes, + storageQuotaBytes: quotas.quotaBytes, + trafficBytes: delta( + traffic.uploadBytes + traffic.downloadBytes, + previousTraffic.uploadBytes + previousTraffic.downloadBytes, + ), + uploadBytes: delta(traffic.uploadBytes, previousTraffic.uploadBytes), + downloadBytes: delta(traffic.downloadBytes, previousTraffic.downloadBytes), + activeShares: sharing.activeShares, + shareViews: delta(sharing.views, (await getSharingEventTotals(db, previous)).views), + shareDownloads: delta(sharing.downloads, (await getSharingEventTotals(db, previous)).downloads), + }, + trends, + } +} + +async function getDashboardGrowthStats( + db: Database, + now: Date, + range: AdminStatsDateRange, +): Promise { + const previous = previousRange(range) + const [usersRows, newUsers, previousNewUsers, activeUsers, previousActiveUsers, activeByDay, registrationSources] = + await Promise.all([ + db + .select({ id: user.id, emailVerified: user.emailVerified, banned: user.banned, createdAt: user.createdAt }) + .from(user), + countRowsWhere(db, user, and(gte(user.createdAt, range.from), lte(user.createdAt, range.to))), + countRowsWhere(db, user, and(gte(user.createdAt, previous.from), lte(user.createdAt, previous.to))), + countActiveUsers(db, range), + countActiveUsers(db, previous), + getRollingActiveUserTrend(db, range), + getRegistrationSources(db, range), + ]) + const activeLast30 = await activeUserIds(db, { from: daysAgo(now, 30), to: now }) + const totalUsers = usersRows.length + const verifiedUsers = usersRows.filter((row) => row.emailVerified).length + const bannedUsers = usersRows.filter((row) => row.banned).length + const unverifiedUsers = usersRows.filter((row) => !row.emailVerified && !row.banned).length + const silentUsers = usersRows.filter((row) => row.emailVerified && !row.banned && !activeLast30.has(row.id)).length + const normalUsers = Math.max(0, totalUsers - unverifiedUsers - bannedUsers - silentUsers) + const newUsersByDay = await getNewUsersByDay(db, range) + let totalAtStart = usersRows.filter((row) => row.createdAt < range.from).length + const userScaleTrend = createDateBuckets(range).map((date) => { + const dailyNew = newUsersByDay.get(date) ?? 0 + totalAtStart += dailyNew + return { date, newUsers: dailyNew, totalUsers: totalAtStart } + }) + + return { + ...statsFrame(now, range), + summary: { + totalUsers, + newUsers: delta(newUsers, previousNewUsers), + activeUsers: delta(activeUsers, previousActiveUsers), + verifiedUsers, + bannedUsers, + silentUsers, + }, + userScaleTrend, + activeUserTrend: activeByDay, + userStatus: percentRows([ + { name: 'normal', value: normalUsers }, + { name: 'unverified', value: unverifiedUsers }, + { name: 'banned', value: bannedUsers }, + { name: 'silent', value: silentUsers }, + ]), + registrationSources, + } +} + +async function getDashboardStorageStats( + db: Database, + now: Date, + range: AdminStatsDateRange, +): Promise { + const previous = previousRange(range) + const [quotas, files, newFiles, previousNewFiles, uploadBytes, previousUploadBytes, typeBreakdown] = + await Promise.all([ + getQuotaTotals(db), + db + .select({ id: matters.id, type: matters.type, size: matters.size, createdAt: matters.createdAt }) + .from(matters) + .where(and(eq(matters.status, 'active'), eq(matters.dirtype, 0))), + countRowsWhere( + db, + matters, + and( + eq(matters.status, 'active'), + eq(matters.dirtype, 0), + gte(matters.createdAt, range.from), + lte(matters.createdAt, range.to), + ), + ), + countRowsWhere( + db, + matters, + and( + eq(matters.status, 'active'), + eq(matters.dirtype, 0), + gte(matters.createdAt, previous.from), + lte(matters.createdAt, previous.to), + ), + ), + getActivityBytesTotal(db, range, ['upload_confirm']), + getActivityBytesTotal(db, previous, ['upload_confirm']), + getStorageByType(db), + ]) + const uploadByDay = await getActivityBytesByDay(db, range, ['upload_confirm']) + const newFilesByDay = await getMatterCreatesByDay(db, range) + const storageBase = quotas.usedBytes - sumMap(uploadByDay) + let runningStorage = storageBase + const storageTrend = createDateBuckets(range).map((date) => { + runningStorage += uploadByDay.get(date) ?? 0 + return { + date, + usedBytes: Math.max(0, runningStorage), + newBytes: uploadByDay.get(date) ?? 0, + newFiles: newFilesByDay.get(date) ?? 0, + } + }) + const fileItems = files.map((row) => ({ bytes: row.size ?? 0, createdAt: row.createdAt })) + const coldCutoff = daysAgo(now, 90) + const coldFileBytes = fileItems.filter((row) => row.createdAt < coldCutoff).reduce((sum, row) => sum + row.bytes, 0) + + return { + ...statsFrame(now, range), + summary: { + storageUsedBytes: quotas.usedBytes, + quotaBytes: quotas.quotaBytes, + fileCount: files.length, + newFiles: delta(newFiles, previousNewFiles), + newBytes: delta(uploadBytes, previousUploadBytes), + coldFileBytes, + }, + storageTrend, + typeBreakdown: percentByBytes( + typeBreakdown.map((row) => ({ name: row.type, bytes: row.bytes, files: row.files })), + ).map(({ name, ...row }) => ({ + type: name, + ...row, + })), + sizeBreakdown: fileBucketBreakdown(fileItems, [ + { name: '<10MB', max: 10 * 1024 * 1024 }, + { name: '10-100MB', max: 100 * 1024 * 1024 }, + { name: '100MB-1GB', max: 1024 * 1024 * 1024 }, + { name: '>1GB', max: Number.POSITIVE_INFINITY }, + ]), + ageBreakdown: ageBucketBreakdown(fileItems, now), + } +} + +async function getDashboardTrafficStats( + db: Database, + now: Date, + range: AdminStatsDateRange, +): Promise { + const previous = previousRange(range) + const [ + traffic, + previousTraffic, + uploadByDay, + downloadByDay, + uploadRequestsByDay, + downloadRequestsByDay, + cloudRows, + downloadRows, + ] = await Promise.all([ + getTrafficTotals(db, range), + getTrafficTotals(db, previous), + getActivityBytesByDay(db, range, ['upload_confirm']), + getDownloadBytesByDay(db, range), + getActivityCountByDay(db, range, ['upload_confirm']), + getDownloadRequestsByDay(db, range), + getCloudTrafficRows(db, range), + getDownloadIssueRows(db, range), + ]) + const sourceRows = new Map() + if (traffic.uploadBytes > 0 || traffic.uploadRequests > 0) { + sourceRows.set('upload', { name: 'upload', bytes: traffic.uploadBytes, requests: traffic.uploadRequests }) + } + for (const row of downloadRows) { + const item = sourceRows.get(row.source) ?? { name: row.source, bytes: 0, requests: 0 } + item.bytes += row.bytes + item.requests += 1 + sourceRows.set(row.source, item) + } + for (const row of cloudRows) { + if (isAuditedDownloadSource(row.source) || row.status === 'blocked') continue + const item = sourceRows.get(row.source) ?? { name: row.source, bytes: 0, requests: 0 } + item.bytes += row.bytes + item.requests += 1 + sourceRows.set(row.source, item) + } + const statusRows = countBy(cloudRows, (row) => row.status) + const trafficTrend = createDateBuckets(range).map((date) => ({ + date, + uploadBytes: uploadByDay.get(date) ?? 0, + downloadBytes: downloadByDay.get(date) ?? 0, + requests: (uploadRequestsByDay.get(date) ?? 0) + (downloadRequestsByDay.get(date) ?? 0), + })) + const totalRequests = traffic.uploadRequests + traffic.downloadRequests + const blockedDownloads = cloudRows.filter((row) => row.status === 'blocked').length + const issuedDownloads = Math.max(0, traffic.downloadRequests - blockedDownloads) + + return { + ...statsFrame(now, range), + summary: { + totalBytes: delta( + traffic.uploadBytes + traffic.downloadBytes, + previousTraffic.uploadBytes + previousTraffic.downloadBytes, + ), + requestCount: delta(totalRequests, previousTraffic.uploadRequests + previousTraffic.downloadRequests), + issuedDownloads, + blockedDownloads, + issueRate: percent(issuedDownloads, issuedDownloads + blockedDownloads), + peakDailyBytes: Math.max(0, ...trafficTrend.map((row) => row.uploadBytes + row.downloadBytes)), + }, + trafficTrend, + sourceBreakdown: percentRows([...sourceRows.values()], (row) => row.bytes), + issueStatus: percentRows( + [...statusRows.entries()].map(([status, countValue]) => ({ status, name: status, value: countValue })), + ).map(({ name, value, percent: pct }) => ({ status: name, count: value, percent: pct })), + bandwidthTrend: trafficTrend.map((row) => ({ date: row.date, bytes: row.uploadBytes + row.downloadBytes })), + } +} + +async function getDashboardSharingStats( + db: Database, + now: Date, + range: AdminStatsDateRange, +): Promise { + const previous = previousRange(range) + const [sharing, previousSharing, topShares, shareRows, saveCount, previousSaveCount] = await Promise.all([ + getSharingEventTotals(db, range), + getSharingEventTotals(db, previous), + getTopSharesWithPercent(db), + db.select({ kind: shares.kind, status: shares.status, createdAt: shares.createdAt }).from(shares), + countActivityEvents(db, range, ['save_from_share']), + countActivityEvents(db, previous, ['save_from_share']), + ]) + const [viewsByDay, downloadsByDay, savesByDay] = await Promise.all([ + getActivityCountByDay(db, range, ['share_view']), + getActivityCountByDay(db, range, ['share_download']), + getActivityCountByDay(db, range, ['save_from_share']), + ]) + const trend = createDateBuckets(range).map((date) => ({ + date, + views: viewsByDay.get(date) ?? 0, + downloads: downloadsByDay.get(date) ?? 0, + saves: savesByDay.get(date) ?? 0, + })) + const createdInRange = shareRows.filter((row) => row.createdAt >= range.from && row.createdAt <= range.to).length + const createdPrevious = shareRows.filter( + (row) => row.createdAt >= previous.from && row.createdAt <= previous.to, + ).length + const activeShares = shareRows.filter((row) => row.status === 'active').length + const typeCounts = countBy( + shareRows.filter((row) => row.createdAt >= range.from && row.createdAt <= range.to), + (row) => row.kind, + ) + + return { + ...statsFrame(now, range), + summary: { + activeShares, + createdShares: delta(createdInRange, createdPrevious), + views: delta(sharing.views, previousSharing.views), + downloads: delta(sharing.downloads, previousSharing.downloads), + saves: delta(saveCount, previousSaveCount), + downloadConversionRate: percent(sharing.downloads, sharing.views), + }, + funnel: percentRows([ + { name: 'views', value: sharing.views }, + { name: 'password_passed', value: sharing.passwordPasses }, + { name: 'downloads', value: sharing.downloads }, + { name: 'saved_to_drive', value: saveCount }, + ]), + trend, + typeBreakdown: percentRows([...typeCounts.entries()].map(([name, value]) => ({ name, value }))), + sourceBreakdown: percentRows([ + { name: 'landing_share', value: sharing.downloads }, + { name: 'save_to_drive', value: saveCount }, + ]), + topShares, + } +} + +async function getDashboardRankingStats( + db: Database, + now: Date, + range: AdminStatsDateRange, +): Promise { + const [topShares, topSpaces, storageByType] = await Promise.all([ + getTopSharesWithPercent(db), + getUsageBySpaceRows(db), + getStorageByType(db), + ]) + return { + ...statsFrame(now, range), + topShares, + topSpaces, + storageByType, + } +} + +function statsFrame(now: Date, range: AdminStatsDateRange) { + return { generatedAt: now.toISOString(), from: range.from.toISOString(), to: range.to.toISOString() } +} + +function previousRange(range: AdminStatsDateRange): AdminStatsDateRange { + const durationMs = Math.max(0, range.to.getTime() - range.from.getTime()) + const to = new Date(range.from.getTime() - 1) + return { from: new Date(to.getTime() - durationMs), to } +} + +function delta(value: number, previousValue: number): AdminStatsDelta { + return { value, previousValue, changePercent: percent(value - previousValue, previousValue) } +} + +function createDateBuckets(range: AdminStatsDateRange): string[] { + const start = startOfDay(range.from) + const end = startOfDay(range.to) + const dates: string[] = [] + for (const date = new Date(start); date <= end; date.setUTCDate(date.getUTCDate() + 1)) { + dates.push(dayKey(date)) + } + return dates +} + +async function getQuotaTotals(db: Database): Promise<{ usedBytes: number; quotaBytes: number }> { + const rows = await db + .select({ + usedBytes: sql`COALESCE(SUM(${orgQuotas.used}), 0)`, + quotaBytes: sql`COALESCE(SUM(${orgQuotas.quota}), 0)`, + }) + .from(orgQuotas) + return { usedBytes: toNumber(rows[0]?.usedBytes), quotaBytes: toNumber(rows[0]?.quotaBytes) } +} + +async function countActiveUsers(db: Database, range: AdminStatsDateRange): Promise { + return (await activeUserIds(db, range)).size +} + +async function activeUserIds(db: Database, range: AdminStatsDateRange): Promise> { + const [activityRows, sessionRows] = await Promise.all([ + db + .select({ userId: activityEvents.userId }) + .from(activityEvents) + .where(and(gte(activityEvents.createdAt, range.from), lte(activityEvents.createdAt, range.to))), + db + .select({ userId: session.userId }) + .from(session) + .where(and(gte(session.updatedAt, range.from), lte(session.updatedAt, range.to))), + ]) + const ids = new Set() + for (const row of activityRows) if (row.userId) ids.add(row.userId) + for (const row of sessionRows) ids.add(row.userId) + return ids +} + +async function getNewUsersByDay(db: Database, range: AdminStatsDateRange): Promise> { + const rows = await db + .select({ createdAt: user.createdAt }) + .from(user) + .where(and(gte(user.createdAt, range.from), lte(user.createdAt, range.to))) + const byDay = new Map() + for (const row of rows) incrementMap(byDay, dayKey(row.createdAt), 1) + return byDay +} + +async function getActiveUsersByDay(db: Database, range: AdminStatsDateRange): Promise>> { + const [activityRows, sessionRows] = await Promise.all([ + db + .select({ userId: activityEvents.userId, createdAt: activityEvents.createdAt }) + .from(activityEvents) + .where(and(gte(activityEvents.createdAt, range.from), lte(activityEvents.createdAt, range.to))), + db + .select({ userId: session.userId, updatedAt: session.updatedAt }) + .from(session) + .where(and(gte(session.updatedAt, range.from), lte(session.updatedAt, range.to))), + ]) + const byDay = new Map>() + for (const row of activityRows) { + if (row.userId) addSetMap(byDay, dayKey(row.createdAt), row.userId) + } + for (const row of sessionRows) addSetMap(byDay, dayKey(row.updatedAt), row.userId) + return byDay +} + +async function getRollingActiveUserTrend( + db: Database, + range: AdminStatsDateRange, +): Promise> { + const extendedRange = { from: daysAgo(range.from, 29), to: range.to } + const [activityRows, sessionRows] = await Promise.all([ + db + .select({ userId: activityEvents.userId, createdAt: activityEvents.createdAt }) + .from(activityEvents) + .where(and(gte(activityEvents.createdAt, extendedRange.from), lte(activityEvents.createdAt, extendedRange.to))), + db + .select({ userId: session.userId, updatedAt: session.updatedAt }) + .from(session) + .where(and(gte(session.updatedAt, extendedRange.from), lte(session.updatedAt, extendedRange.to))), + ]) + const records: Array<{ userId: string; at: Date }> = [] + for (const row of activityRows) if (row.userId) records.push({ userId: row.userId, at: row.createdAt }) + for (const row of sessionRows) records.push({ userId: row.userId, at: row.updatedAt }) + return createDateBuckets(range).map((dateKeyValue) => { + const dayEnd = new Date(`${dateKeyValue}T23:59:59.999Z`) + const dauStart = new Date(`${dateKeyValue}T00:00:00.000Z`) + const wauStart = daysAgo(dayEnd, 6) + const mauStart = daysAgo(dayEnd, 29) + return { + date: dateKeyValue, + dau: distinctUsersInWindow(records, dauStart, dayEnd), + wau: distinctUsersInWindow(records, wauStart, dayEnd), + mau: distinctUsersInWindow(records, mauStart, dayEnd), + } + }) +} + +async function getRegistrationSources( + db: Database, + range: AdminStatsDateRange, +): Promise> { + const newUsers = await db + .select({ id: user.id }) + .from(user) + .where(and(gte(user.createdAt, range.from), lte(user.createdAt, range.to))) + if (newUsers.length === 0) return [] + const userIds = newUsers.map((row) => row.id) + const accounts = await db + .select({ userId: account.userId, providerId: account.providerId }) + .from(account) + .where(inArray(account.userId, userIds)) + const seen = new Set() + const counts = new Map() + for (const row of accounts) { + if (seen.has(row.userId)) continue + seen.add(row.userId) + incrementMap(counts, row.providerId || 'unknown', 1) + } + const direct = userIds.length - seen.size + if (direct > 0) counts.set('direct', direct) + return percentRows([...counts.entries()].map(([name, value]) => ({ name, value }))) +} + +async function getActivityBytesTotal(db: Database, range: AdminStatsDateRange, actions: string[]): Promise { + const rows = await getActivityMetadataRows(db, range, actions) + return rows.reduce((sum, row) => sum + metadataNumber(row.metadata, 'bytes'), 0) +} + +async function getActivityBytesByDay( + db: Database, + range: AdminStatsDateRange, + actions: string[], +): Promise> { + const rows = await getActivityMetadataRows(db, range, actions) + const byDay = new Map() + for (const row of rows) incrementMap(byDay, dayKey(row.createdAt), metadataNumber(row.metadata, 'bytes')) + return byDay +} + +async function getActivityCountByDay( + db: Database, + range: AdminStatsDateRange, + actions: string[], +): Promise> { + const rows = await getActivityMetadataRows(db, range, actions) + const byDay = new Map() + for (const row of rows) incrementMap(byDay, dayKey(row.createdAt), 1) + return byDay +} + +async function countActivityEvents(db: Database, range: AdminStatsDateRange, actions: string[]): Promise { + if (actions.length === 0) return 0 + return countRowsWhere( + db, + activityEvents, + and( + inArray(activityEvents.action, actions), + gte(activityEvents.createdAt, range.from), + lte(activityEvents.createdAt, range.to), + ), + ) +} + +async function getActivityMetadataRows(db: Database, range: AdminStatsDateRange, actions: string[]) { + if (actions.length === 0) return [] + return db + .select({ action: activityEvents.action, metadata: activityEvents.metadata, createdAt: activityEvents.createdAt }) + .from(activityEvents) + .where( + and( + inArray(activityEvents.action, actions), + gte(activityEvents.createdAt, range.from), + lte(activityEvents.createdAt, range.to), + ), + ) +} + +async function getMatterCreatesByDay(db: Database, range: AdminStatsDateRange): Promise> { + const rows = await db + .select({ createdAt: matters.createdAt }) + .from(matters) + .where( + and( + eq(matters.status, 'active'), + eq(matters.dirtype, 0), + gte(matters.createdAt, range.from), + lte(matters.createdAt, range.to), + ), + ) + const byDay = new Map() + for (const row of rows) incrementMap(byDay, dayKey(row.createdAt), 1) + return byDay +} + +async function getCloudTrafficRows(db: Database, range: AdminStatsDateRange) { + return db + .select({ + source: cloudTrafficReports.source, + bytes: cloudTrafficReports.bytes, + status: cloudTrafficReports.status, + createdAt: cloudTrafficReports.createdAt, + }) + .from(cloudTrafficReports) + .where(and(gte(cloudTrafficReports.createdAt, range.from), lte(cloudTrafficReports.createdAt, range.to))) +} + +async function getDownloadIssueRows( + db: Database, + range: AdminStatsDateRange, +): Promise> { + const rows = await getActivityMetadataRows(db, range, DOWNLOAD_ACTIVITY_ACTIONS) + return rows.map((row) => { + const metadata = parseMetadata(row.metadata) + return { + source: + typeof metadata.source === 'string' + ? metadata.source + : row.action === 'object_download' + ? 'object_download' + : 'landing_share', + bytes: toNumber(metadata.bytes), + createdAt: row.createdAt, + } + }) +} + +async function getDownloadBytesByDay(db: Database, range: AdminStatsDateRange): Promise> { + const [activityRows, cloudRows] = await Promise.all([getDownloadIssueRows(db, range), getCloudTrafficRows(db, range)]) + const byDay = new Map() + for (const row of activityRows) incrementMap(byDay, dayKey(row.createdAt), row.bytes) + for (const row of cloudRows) { + if (isAuditedDownloadSource(row.source) || row.status === 'blocked') continue + incrementMap(byDay, dayKey(row.createdAt), row.bytes) + } + return byDay +} + +async function getDownloadRequestsByDay(db: Database, range: AdminStatsDateRange): Promise> { + const [activityRows, cloudRows] = await Promise.all([getDownloadIssueRows(db, range), getCloudTrafficRows(db, range)]) + const byDay = new Map() + for (const row of activityRows) incrementMap(byDay, dayKey(row.createdAt), 1) + for (const row of cloudRows) { + if (isAuditedDownloadSource(row.source) && row.status !== 'blocked') continue + incrementMap(byDay, dayKey(row.createdAt), 1) + } + return byDay +} + +async function getTrafficTotals( + db: Database, + range: AdminStatsDateRange, +): Promise<{ uploadBytes: number; uploadRequests: number; downloadBytes: number; downloadRequests: number }> { + const [uploadBytes, uploadRequests, activityDownloadRows, cloudRows] = await Promise.all([ + getActivityBytesTotal(db, range, ['upload_confirm']), + countActivityEvents(db, range, ['upload_confirm']), + getDownloadIssueRows(db, range), + getCloudTrafficRows(db, range), + ]) + const cloudDownloadRows = cloudRows.filter((row) => !isAuditedDownloadSource(row.source) && row.status !== 'blocked') + const blockedDownloadRows = cloudRows.filter((row) => row.status === 'blocked') + return { + uploadBytes, + uploadRequests, + downloadBytes: + activityDownloadRows.reduce((sum, row) => sum + row.bytes, 0) + + cloudDownloadRows.reduce((sum, row) => sum + row.bytes, 0), + downloadRequests: activityDownloadRows.length + cloudDownloadRows.length + blockedDownloadRows.length, + } +} + +function isAuditedDownloadSource(source: string): boolean { + return AUDITED_DOWNLOAD_SOURCES.has(source) +} + +async function getSharingEventTotals( + db: Database, + range: AdminStatsDateRange, +): Promise<{ activeShares: number; views: number; passwordPasses: number; downloads: number }> { + const [activeShares, views, passwordPasses, downloads] = await Promise.all([ + countRowsWhere(db, shares, eq(shares.status, 'active')), + countActivityEvents(db, range, ['share_view']), + countActivityEvents(db, range, ['share_password_passed']), + countActivityEvents(db, range, ['share_download']), + ]) + return { activeShares, views, passwordPasses, downloads } +} + +async function getTopSharesWithPercent( + db: Database, +): Promise> { + const rows = await getTopShares(db) + const totalViews = rows.reduce((sum, row) => sum + row.views, 0) + const totalDownloads = rows.reduce((sum, row) => sum + row.downloads, 0) + return rows.map((row) => ({ + ...row, + viewPercent: percent(row.views, totalViews), + downloadPercent: percent(row.downloads, totalDownloads), + })) +} + +async function getUsageBySpaceRows(db: Database): Promise { + const rows = await db + .select({ + orgId: orgQuotas.orgId, + usedBytes: orgQuotas.used, + quotaBytes: orgQuotas.quota, + orgName: organization.name, + slug: organization.slug, + metadata: organization.metadata, + }) + .from(orgQuotas) + .leftJoin(organization, eq(organization.id, orgQuotas.orgId)) + .orderBy(desc(orgQuotas.used)) + .limit(8) + return rows.map((row) => ({ + orgId: row.orgId, + orgName: row.orgName ?? row.orgId, + orgType: isPersonalOrgLike({ slug: row.slug ?? '', metadata: row.metadata ?? null }) ? 'personal' : 'team', + usedBytes: row.usedBytes, + quotaBytes: row.quotaBytes, + utilization: percent(row.usedBytes, row.quotaBytes), + })) +} + +function percentRows( + rows: T[], + getValue: (row: T) => number = (row) => ('value' in row ? toNumber(row.value) : 0), +): Array { + const total = rows.reduce((sum, row) => sum + getValue(row), 0) + return rows.map((row) => { + const value = getValue(row) + return { ...row, value, percent: percent(value, total) } + }) +} + +function percentByBytes(rows: T[]): Array { + const total = rows.reduce((sum, row) => sum + row.bytes, 0) + return rows.map((row) => ({ ...row, percent: percent(row.bytes, total) })) +} + +function fileBucketBreakdown( + files: Array<{ bytes: number }>, + buckets: Array<{ name: string; max: number }>, +): Array<{ name: string; bytes: number; files: number; percent: number }> { + const rows = buckets.map((bucket) => ({ name: bucket.name, bytes: 0, files: 0, max: bucket.max })) + for (const file of files) { + const bucket = rows.find((row, index) => file.bytes <= row.max && (index === 0 || file.bytes > rows[index - 1].max)) + if (!bucket) continue + bucket.files += 1 + bucket.bytes += file.bytes + } + return percentByBytes(rows.map(({ max: _max, ...row }) => row)) +} + +function ageBucketBreakdown( + files: Array<{ bytes: number; createdAt: Date }>, + now: Date, +): Array<{ name: string; bytes: number; files: number; percent: number }> { + const rows = [ + { name: '<30d', bytes: 0, files: 0 }, + { name: '30-90d', bytes: 0, files: 0 }, + { name: '90-180d', bytes: 0, files: 0 }, + { name: '>180d', bytes: 0, files: 0 }, + ] + for (const file of files) { + const ageDays = Math.floor((now.getTime() - file.createdAt.getTime()) / 86_400_000) + const bucket = ageDays < 30 ? rows[0] : ageDays < 90 ? rows[1] : ageDays < 180 ? rows[2] : rows[3] + bucket.files += 1 + bucket.bytes += file.bytes + } + return percentByBytes(rows) +} + +function metadataNumber(metadata: string | null, key: string): number { + const parsed = parseMetadata(metadata) + return toNumber(parsed[key]) +} + +function parseMetadata(metadata: string | null): Record { + if (!metadata) return {} + try { + const parsed = JSON.parse(metadata) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {} + } catch { + return {} + } +} + +function incrementMap(map: Map, key: string, value: number): void { + map.set(key, (map.get(key) ?? 0) + value) +} + +function addSetMap(map: Map>, key: string, value: string): void { + const set = map.get(key) ?? new Set() + set.add(value) + map.set(key, set) +} + +function sumMap(map: Map): number { + let total = 0 + for (const value of map.values()) total += value + return total +} + +function countBy(rows: T[], getKey: (row: T) => string): Map { + const map = new Map() + for (const row of rows) incrementMap(map, getKey(row), 1) + return map +} + +function distinctUsersInWindow(records: Array<{ userId: string; at: Date }>, from: Date, to: Date): number { + const ids = new Set() + for (const record of records) { + if (record.at >= from && record.at <= to) ids.add(record.userId) + } + return ids.size +} + function createTrendBuckets(start: Date, periodDays: number): Map { const buckets = new Map() for (let i = 0; i < periodDays; i += 1) { diff --git a/server/adapters/repos/cloud-store.ts b/server/adapters/repos/cloud-store.ts index fcb2b821..11402e42 100644 --- a/server/adapters/repos/cloud-store.ts +++ b/server/adapters/repos/cloud-store.ts @@ -95,7 +95,9 @@ function quotaChangeAuditValues(event: CloudOrderQuotaChange): typeof activityEv return { id: nanoid(), orgId: event.targetOrgId, - userId: event.customerId ?? 'cloud-store', + userId: null, + actorType: 'system', + actorRef: 'cloud-store', action: `quota_order_${event.direction}`, targetType: 'quota', targetId: event.targetOrgId, @@ -103,6 +105,7 @@ function quotaChangeAuditValues(event: CloudOrderQuotaChange): typeof activityEv metadata: JSON.stringify({ eventId: event.eventId, eventType: event.eventType, + customerId: event.customerId ?? null, direction: event.direction, storageBytes: event.storageBytes, trafficBytes: event.trafficBytes, diff --git a/server/db/schema.ts b/server/db/schema.ts index 3ebe0fc5..3d5b603d 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -437,16 +437,88 @@ export const announcements = sqliteTable( ], ) -export const activityEvents = sqliteTable('activity_events', { - id: text('id').primaryKey(), - orgId: text('org_id').notNull(), - userId: text('user_id').notNull(), - action: text('action').notNull(), // 'upload', 'create', 'delete', 'rename', 'move', 'restore' - targetType: text('target_type').notNull(), // 'file', 'folder' - targetId: text('target_id'), - targetName: text('target_name').notNull(), - metadata: text('metadata'), // JSON - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), +export const activityEvents = sqliteTable( + 'activity_events', + { + id: text('id').primaryKey(), + orgId: text('org_id').notNull(), + userId: text('user_id'), + actorType: text('actor_type').notNull().default('user'), + actorRef: text('actor_ref'), + action: text('action').notNull(), // 'upload', 'create', 'delete', 'rename', 'move', 'restore' + targetType: text('target_type').notNull(), // 'file', 'folder' + targetId: text('target_id'), + targetName: text('target_name').notNull(), + metadata: text('metadata'), // JSON + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('activity_events_org_created_idx').on(t.orgId, t.createdAt), + index('activity_events_user_created_idx').on(t.userId, t.createdAt), + index('activity_events_action_created_idx').on(t.action, t.createdAt), + index('activity_events_target_created_idx').on(t.targetType, t.targetId, t.createdAt), + ], +) + +export const statsRollupsHourly = sqliteTable( + 'stats_rollups_hourly', + { + id: text('id').primaryKey(), + bucketStart: integer('bucket_start', { mode: 'timestamp_ms' }).notNull(), + orgId: text('org_id').notNull().default(''), + metricKey: text('metric_key').notNull(), + dimensionKey: text('dimension_key').notNull().default(''), + dimensionValue: text('dimension_value').notNull().default(''), + count: integer('count').notNull().default(0), + bytes: integer('bytes').notNull().default(0), + uniqueCount: integer('unique_count').notNull().default(0), + metadata: text('metadata'), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), + }, + (t) => [ + uniqueIndex('stats_rollups_hourly_bucket_metric_dim_uniq').on( + t.bucketStart, + t.orgId, + t.metricKey, + t.dimensionKey, + t.dimensionValue, + ), + index('stats_rollups_hourly_metric_bucket_idx').on(t.metricKey, t.bucketStart), + ], +) + +export const statsRollupsDaily = sqliteTable( + 'stats_rollups_daily', + { + id: text('id').primaryKey(), + bucketStart: integer('bucket_start', { mode: 'timestamp_ms' }).notNull(), + orgId: text('org_id').notNull().default(''), + metricKey: text('metric_key').notNull(), + dimensionKey: text('dimension_key').notNull().default(''), + dimensionValue: text('dimension_value').notNull().default(''), + count: integer('count').notNull().default(0), + bytes: integer('bytes').notNull().default(0), + uniqueCount: integer('unique_count').notNull().default(0), + metadata: text('metadata'), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), + }, + (t) => [ + uniqueIndex('stats_rollups_daily_bucket_metric_dim_uniq').on( + t.bucketStart, + t.orgId, + t.metricKey, + t.dimensionKey, + t.dimensionValue, + ), + index('stats_rollups_daily_metric_bucket_idx').on(t.metricKey, t.bucketStart), + ], +) + +export const statsRollupState = sqliteTable('stats_rollup_state', { + jobName: text('job_name').primaryKey(), + cursorCreatedAt: integer('cursor_created_at', { mode: 'timestamp_ms' }), + cursorId: text('cursor_id'), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), }) export const shares = sqliteTable( diff --git a/server/http/admin-stats.integration.test.ts b/server/http/admin-stats.integration.test.ts index d3c8526b..df25912d 100644 --- a/server/http/admin-stats.integration.test.ts +++ b/server/http/admin-stats.integration.test.ts @@ -68,6 +68,42 @@ describe('admin stats routes', () => { expect(body.reliability.license.lastRefreshAt).toMatch(/^20\d{2}-/) expect(body.trends.some((point) => point.remoteTasks > 0 || point.failedJobs > 0)).toBe(true) }) + + it('returns traffic dashboard stats from audit-backed download events for Pro admins', async () => { + const { app, db } = await createTestApp() + const headers = await adminHeaders(app) + await seedProLicense(db) + await seedStatsFixture(db) + + const res = await app.request( + '/api/admin/stats/traffic?from=2020-01-01T00:00:00.000Z&to=2100-01-01T00:00:00.000Z', + { + headers, + }, + ) + const body = (await res.json()) as { + summary: { + totalBytes: { value: number } + requestCount: { value: number } + issuedDownloads: number + blockedDownloads: number + } + sourceBreakdown: Array<{ name: string; bytes: number; requests: number }> + } + + expect(res.status).toBe(200) + expect(body.summary.totalBytes.value).toBe(896) + expect(body.summary.requestCount.value).toBe(3) + expect(body.summary.issuedDownloads).toBe(2) + expect(body.summary.blockedDownloads).toBe(0) + expect(body.sourceBreakdown).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'upload', bytes: 128, requests: 1 }), + expect.objectContaining({ name: 'landing_share', bytes: 512, requests: 1 }), + expect.objectContaining({ name: 'object_download', bytes: 256, requests: 1 }), + ]), + ) + }) }) async function seedStatsFixture(db: Awaited>['db']) { @@ -106,6 +142,13 @@ async function seedStatsFixture(db: Awaited>['d INSERT INTO activity_events (id, org_id, user_id, action, target_type, target_id, target_name, metadata, created_at) VALUES ('activity-1', ${orgId}, ${userId}, 'upload', 'file', 'stats-file', 'report.pdf', NULL, ${nowSec}) `) + await db.run(sql` + INSERT INTO activity_events (id, org_id, user_id, actor_type, action, target_type, target_id, target_name, metadata, created_at) + VALUES + ('activity-upload-confirm', ${orgId}, ${userId}, 'user', 'upload_confirm', 'file', 'stats-file', 'report.pdf', '{"bytes":128,"source":"upload"}', ${nowSec}), + ('activity-share-download', ${orgId}, NULL, 'anonymous', 'share_download', 'share', 'share-1', 'report.pdf', '{"bytes":512,"source":"landing_share","anonymous":true}', ${nowSec}), + ('activity-object-download', ${orgId}, ${userId}, 'user', 'object_download', 'file', 'stats-file', 'report.pdf', '{"bytes":256,"source":"object_download"}', ${nowSec}) + `) await db.run(sql` INSERT INTO downloaders (id, name, token_hash, token_jti, status, enabled, version, hostname, platform, arch, engine, capabilities, max_concurrent_tasks, current_tasks, download_bps, upload_bps, free_disk_bytes, created_by, last_heartbeat_at, created_at, updated_at) VALUES ('downloader-1', 'Downloader One', 'hash', 'jti', 'online', 1, '1.0.0', 'host', 'linux', 'x64', 'http', '[]', 2, 0, 0, 0, 1000, ${userId}, ${now}, ${now}, ${now}) diff --git a/server/http/admin-stats.ts b/server/http/admin-stats.ts index 5506300c..f712541c 100644 --- a/server/http/admin-stats.ts +++ b/server/http/admin-stats.ts @@ -2,7 +2,16 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' import { requireFeature } from '../middleware/require-feature' -import { getAdminCoreStats, getAdminDetailedStats } from '../usecases/admin-stats' +import { + getAdminCoreStats, + getAdminDashboardGrowthStats, + getAdminDashboardOverviewStats, + getAdminDashboardRankingStats, + getAdminDashboardSharingStats, + getAdminDashboardStorageStats, + getAdminDashboardTrafficStats, + getAdminDetailedStats, +} from '../usecases/admin-stats' import { errorResponse, jsonContent } from './openapi' const coreStatsSchema = z @@ -139,6 +148,20 @@ const detailsQuerySchema = z.object({ periodDays: z.coerce.number().int().min(7).max(90).default(30), }) +const rangeQuerySchema = z.object({ + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), +}) + +const dashboardStatsSchema = z.any().openapi('AdminDashboardStats') + +function parseRange(query: z.infer): { from?: Date; to?: Date } { + return { + from: query.from ? new Date(query.from) : undefined, + to: query.to ? new Date(query.to) : undefined, + } +} + const coreRoute = createRoute({ operationId: 'getAdminCoreStats', summary: 'Get admin dashboard core stats', @@ -166,9 +189,112 @@ const detailsRoute = createRoute({ }, }) +const overviewRoute = createRoute({ + operationId: 'getAdminDashboardOverviewStats', + summary: 'Get admin dashboard overview stats', + tags: ['Admin Stats'], + method: 'get', + path: '/overview', + middleware: [requireAdmin] as const, + request: { query: rangeQuerySchema }, + responses: { + 200: jsonContent(dashboardStatsSchema, 'Admin dashboard overview stats'), + 400: errorResponse('Invalid query'), + 401: errorResponse('Unauthorized'), + }, +}) + +const growthRoute = createRoute({ + operationId: 'getAdminDashboardGrowthStats', + summary: 'Get admin dashboard growth stats', + tags: ['Admin Stats'], + method: 'get', + path: '/growth', + middleware: [requireAdmin, requireFeature('analytics')] as const, + request: { query: rangeQuerySchema }, + responses: { + 200: jsonContent(dashboardStatsSchema, 'Admin dashboard growth stats'), + 402: errorResponse('Feature not available'), + }, +}) + +const storageRoute = createRoute({ + operationId: 'getAdminDashboardStorageStats', + summary: 'Get admin dashboard storage stats', + tags: ['Admin Stats'], + method: 'get', + path: '/storage', + middleware: [requireAdmin, requireFeature('analytics')] as const, + request: { query: rangeQuerySchema }, + responses: { + 200: jsonContent(dashboardStatsSchema, 'Admin dashboard storage stats'), + 402: errorResponse('Feature not available'), + }, +}) + +const trafficRoute = createRoute({ + operationId: 'getAdminDashboardTrafficStats', + summary: 'Get admin dashboard traffic stats', + tags: ['Admin Stats'], + method: 'get', + path: '/traffic', + middleware: [requireAdmin, requireFeature('analytics')] as const, + request: { query: rangeQuerySchema }, + responses: { + 200: jsonContent(dashboardStatsSchema, 'Admin dashboard traffic stats'), + 402: errorResponse('Feature not available'), + }, +}) + +const sharingRoute = createRoute({ + operationId: 'getAdminDashboardSharingStats', + summary: 'Get admin dashboard sharing stats', + tags: ['Admin Stats'], + method: 'get', + path: '/sharing', + middleware: [requireAdmin, requireFeature('analytics')] as const, + request: { query: rangeQuerySchema }, + responses: { + 200: jsonContent(dashboardStatsSchema, 'Admin dashboard sharing stats'), + 402: errorResponse('Feature not available'), + }, +}) + +const rankingRoute = createRoute({ + operationId: 'getAdminDashboardRankingStats', + summary: 'Get admin dashboard ranking stats', + tags: ['Admin Stats'], + method: 'get', + path: '/ranking', + middleware: [requireAdmin, requireFeature('analytics')] as const, + request: { query: rangeQuerySchema }, + responses: { + 200: jsonContent(dashboardStatsSchema, 'Admin dashboard ranking stats'), + 402: errorResponse('Feature not available'), + }, +}) + export const adminStats = new OpenAPIHono() .openapi(coreRoute, async (c) => c.json(await getAdminCoreStats(c.get('deps')), 200)) .openapi(detailsRoute, async (c) => { const { periodDays } = c.req.valid('query') return c.json(await getAdminDetailedStats(c.get('deps'), { periodDays }), 200) }) + .openapi(overviewRoute, async (c) => + c.json(await getAdminDashboardOverviewStats(c.get('deps'), parseRange(c.req.valid('query'))), 200), + ) + .openapi(growthRoute, async (c) => + c.json(await getAdminDashboardGrowthStats(c.get('deps'), parseRange(c.req.valid('query'))), 200), + ) + .openapi(storageRoute, async (c) => + c.json(await getAdminDashboardStorageStats(c.get('deps'), parseRange(c.req.valid('query'))), 200), + ) + .openapi(trafficRoute, async (c) => + c.json(await getAdminDashboardTrafficStats(c.get('deps'), parseRange(c.req.valid('query'))), 200), + ) + .openapi(sharingRoute, async (c) => + c.json(await getAdminDashboardSharingStats(c.get('deps'), parseRange(c.req.valid('query'))), 200), + ) + .openapi(rankingRoute, async (c) => + c.json(await getAdminDashboardRankingStats(c.get('deps'), parseRange(c.req.valid('query'))), 200), + ) diff --git a/server/http/objects.ts b/server/http/objects.ts index b535c0ff..2189410a 100644 --- a/server/http/objects.ts +++ b/server/http/objects.ts @@ -437,6 +437,7 @@ const objects = app const result = await getObject(c.get('deps'), { orgId, objectId: c.req.valid('param').id, + actorId: actorId(c), cloudBaseUrl: cloudBaseUrl(c), }) if (result.ok) { diff --git a/server/http/shares.ts b/server/http/shares.ts index 8612a506..2bc82dc7 100644 --- a/server/http/shares.ts +++ b/server/http/shares.ts @@ -278,7 +278,8 @@ export const publicShares = pub .openapi(verifyShareRoute, async (c) => { const token = c.req.valid('param').token const { password } = c.req.valid('json') - const out = await verifySharePassword(c.get('deps'), { token, password }) + const viewerId = await readUserId(c) + const out = await verifySharePassword(c.get('deps'), { token, password, viewerId }) if (out.ok) { setCookie(c, cookieName(token), 'ok', { httpOnly: true, diff --git a/server/http/site/audit-events.integration.test.ts b/server/http/site/audit-events.integration.test.ts index 3189fe97..f16fa101 100644 --- a/server/http/site/audit-events.integration.test.ts +++ b/server/http/site/audit-events.integration.test.ts @@ -579,7 +579,7 @@ describe('Share download audit events', () => { assertNoSecrets(metaStr) }) - it('records share_download using creator as actor for anonymous download', async () => { + it('records share_download as anonymous without attributing it to the creator', async () => { const { app, db } = await createTestApp() await authedHeaders(app) // seeds user and personal org await insertStorage(db) @@ -599,9 +599,11 @@ describe('Share download audit events', () => { const evt = await getLatestActivity(db, 'share_download') expect(evt).toBeDefined() - expect(evt?.userId).toBe(creatorId) // creator used as proxy for anonymous + expect(evt?.userId).toBeNull() + expect(evt?.actorType).toBe('anonymous') const md = JSON.parse(evt?.metadata ?? '{}') as Record expect(md.anonymous).toBe(true) + expect(md.creatorId).toBe(creatorId) assertNoSecrets(evt?.metadata ?? null) }) }) diff --git a/server/http/site/audit.ts b/server/http/site/audit.ts index 0ef530a2..2b0dd936 100644 --- a/server/http/site/audit.ts +++ b/server/http/site/audit.ts @@ -12,14 +12,16 @@ const auditEventSchema = z .object({ id: z.string(), orgId: z.string(), - userId: z.string(), + userId: z.string().nullable(), + actorType: z.enum(['user', 'anonymous', 'system', 'downloader']), + actorRef: z.string().nullable(), action: z.string(), targetType: z.string(), targetId: z.string().nullable(), targetName: z.string(), metadata: z.string().nullable(), createdAt: z.string(), - user: z.object({ id: z.string(), name: z.string(), image: z.string().nullable() }), + user: z.object({ id: z.string().nullable(), name: z.string(), image: z.string().nullable() }), orgName: z.string().nullable(), }) .openapi('AuditEvent') diff --git a/server/http/teams.ts b/server/http/teams.ts index 2166bc51..3b67b9b0 100644 --- a/server/http/teams.ts +++ b/server/http/teams.ts @@ -74,14 +74,16 @@ const activityEventSchema = z .object({ id: z.string(), orgId: z.string(), - userId: z.string(), + userId: z.string().nullable(), + actorType: z.enum(['user', 'anonymous', 'system', 'downloader']), + actorRef: z.string().nullable(), action: z.string(), targetType: z.string(), targetId: z.string().nullable(), targetName: z.string(), metadata: z.string().nullable(), createdAt: z.string(), - user: z.object({ id: z.string(), name: z.string(), image: z.string().nullable() }), + user: z.object({ id: z.string().nullable(), name: z.string(), image: z.string().nullable() }), }) .openapi('ActivityEvent') diff --git a/server/test/setup.ts b/server/test/setup.ts index ed4c6c4d..7bd63a58 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -284,18 +284,59 @@ const APP_SCHEMA_SQL = ` created_at INTEGER NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS team_invite_links_token_unique ON team_invite_links(token); - CREATE TABLE IF NOT EXISTS activity_events ( - id TEXT PRIMARY KEY, - org_id TEXT NOT NULL, - user_id TEXT NOT NULL, - action TEXT NOT NULL, - target_type TEXT NOT NULL, - target_id TEXT, - target_name TEXT NOT NULL, - metadata TEXT, - created_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS activity_events_org_id_idx ON activity_events(org_id); + CREATE TABLE IF NOT EXISTS activity_events ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + user_id TEXT, + actor_type TEXT NOT NULL DEFAULT 'user', + actor_ref TEXT, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT, + target_name TEXT NOT NULL, + metadata TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS activity_events_org_created_idx ON activity_events(org_id, created_at); + CREATE INDEX IF NOT EXISTS activity_events_user_created_idx ON activity_events(user_id, created_at); + CREATE INDEX IF NOT EXISTS activity_events_action_created_idx ON activity_events(action, created_at); + CREATE INDEX IF NOT EXISTS activity_events_target_created_idx ON activity_events(target_type, target_id, created_at); + CREATE TABLE IF NOT EXISTS stats_rollup_state ( + job_name TEXT PRIMARY KEY NOT NULL, + cursor_created_at INTEGER, + cursor_id TEXT, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS stats_rollups_daily ( + id TEXT PRIMARY KEY NOT NULL, + bucket_start INTEGER NOT NULL, + org_id TEXT NOT NULL DEFAULT '', + metric_key TEXT NOT NULL, + dimension_key TEXT NOT NULL DEFAULT '', + dimension_value TEXT NOT NULL DEFAULT '', + count INTEGER NOT NULL DEFAULT 0, + bytes INTEGER NOT NULL DEFAULT 0, + unique_count INTEGER NOT NULL DEFAULT 0, + metadata TEXT, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS stats_rollups_daily_bucket_metric_dim_uniq ON stats_rollups_daily(bucket_start, org_id, metric_key, dimension_key, dimension_value); + CREATE INDEX IF NOT EXISTS stats_rollups_daily_metric_bucket_idx ON stats_rollups_daily(metric_key, bucket_start); + CREATE TABLE IF NOT EXISTS stats_rollups_hourly ( + id TEXT PRIMARY KEY NOT NULL, + bucket_start INTEGER NOT NULL, + org_id TEXT NOT NULL DEFAULT '', + metric_key TEXT NOT NULL, + dimension_key TEXT NOT NULL DEFAULT '', + dimension_value TEXT NOT NULL DEFAULT '', + count INTEGER NOT NULL DEFAULT 0, + bytes INTEGER NOT NULL DEFAULT 0, + unique_count INTEGER NOT NULL DEFAULT 0, + metadata TEXT, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS stats_rollups_hourly_bucket_metric_dim_uniq ON stats_rollups_hourly(bucket_start, org_id, metric_key, dimension_key, dimension_value); + CREATE INDEX IF NOT EXISTS stats_rollups_hourly_metric_bucket_idx ON stats_rollups_hourly(metric_key, bucket_start); CREATE TABLE IF NOT EXISTS shares ( id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE, diff --git a/server/usecases/admin-stats.ts b/server/usecases/admin-stats.ts index 199614e4..20d80849 100644 --- a/server/usecases/admin-stats.ts +++ b/server/usecases/admin-stats.ts @@ -1,7 +1,23 @@ -import type { AdminCoreStats, AdminDetailedStats, AdminUsageBySpace } from '@shared/types' +import type { + AdminCoreStats, + AdminDashboardGrowthStats, + AdminDashboardOverviewStats, + AdminDashboardRankingStats, + AdminDashboardSharingStats, + AdminDashboardStorageStats, + AdminDashboardTrafficStats, + AdminDetailedStats, + AdminUsageBySpace, +} from '@shared/types' import { currentTrafficPeriod } from '../domain/quota' import { percent } from './admin-stats-utils' -import type { AdminStatsRepo, LicenseBindingRepo, QuotaRepo } from './ports' +import { + type AdminStatsDateRange, + type AdminStatsRepo, + badRequest, + type LicenseBindingRepo, + type QuotaRepo, +} from './ports' import { listQuotaOverview } from './quota' import { loadBindingState } from './site/licensing' @@ -93,8 +109,74 @@ export async function getAdminDetailedStats( } } +export interface AdminStatsRangeInput { + from?: Date + to?: Date +} + +export function normalizeStatsRange(input: AdminStatsRangeInput, now = new Date()): AdminStatsDateRange { + const to = input.to ?? now + const from = input.from ?? daysAgo(to, 29) + if (from > to) throw badRequest('from must be before to', 'INVALID_TIME_RANGE') + return { from, to } +} + +export function getAdminDashboardOverviewStats( + deps: AdminStatsDeps, + input: AdminStatsRangeInput, + now = new Date(), +): Promise { + return deps.adminStats.getDashboardOverviewStats(now, normalizeStatsRange(input, now)) +} + +export function getAdminDashboardGrowthStats( + deps: AdminStatsDeps, + input: AdminStatsRangeInput, + now = new Date(), +): Promise { + return deps.adminStats.getDashboardGrowthStats(now, normalizeStatsRange(input, now)) +} + +export function getAdminDashboardStorageStats( + deps: AdminStatsDeps, + input: AdminStatsRangeInput, + now = new Date(), +): Promise { + return deps.adminStats.getDashboardStorageStats(now, normalizeStatsRange(input, now)) +} + +export function getAdminDashboardTrafficStats( + deps: AdminStatsDeps, + input: AdminStatsRangeInput, + now = new Date(), +): Promise { + return deps.adminStats.getDashboardTrafficStats(now, normalizeStatsRange(input, now)) +} + +export function getAdminDashboardSharingStats( + deps: AdminStatsDeps, + input: AdminStatsRangeInput, + now = new Date(), +): Promise { + return deps.adminStats.getDashboardSharingStats(now, normalizeStatsRange(input, now)) +} + +export function getAdminDashboardRankingStats( + deps: AdminStatsDeps, + input: AdminStatsRangeInput, + now = new Date(), +): Promise { + return deps.adminStats.getDashboardRankingStats(now, normalizeStatsRange(input, now)) +} + function normalizePeriodDays(value: number): number { if (value <= 7) return 7 if (value <= 30) return 30 return 90 } + +function daysAgo(now: Date, days: number): Date { + const date = new Date(now) + date.setUTCDate(date.getUTCDate() - days) + return date +} diff --git a/server/usecases/object.test.ts b/server/usecases/object.test.ts index aee03c47..e2759f1d 100644 --- a/server/usecases/object.test.ts +++ b/server/usecases/object.test.ts @@ -749,7 +749,7 @@ describe('object usecase', () => { it('returns a folder without metering or presigning', async () => { const presignDownload = vi.fn() const { deps } = makeDeps({ matter: { get: async () => folder('f1') }, s3: { presignDownload } }) - const out = await getObject(deps, { orgId: 'o1', objectId: 'f1', cloudBaseUrl: 'https://cloud' }) + const out = await getObject(deps, { orgId: 'o1', objectId: 'f1', actorId: 'u1', cloudBaseUrl: 'https://cloud' }) expect(out).toEqual({ ok: true, matter: folder('f1') }) expect(meterDownloadTraffic).not.toHaveBeenCalled() expect(presignDownload).not.toHaveBeenCalled() @@ -757,21 +757,24 @@ describe('object usecase', () => { it('returns not_found for a missing object', async () => { const { deps } = makeDeps({ matter: { get: async () => null } }) - const out = await getObject(deps, { orgId: 'o1', objectId: 'x', cloudBaseUrl: 'https://cloud' }) + const out = await getObject(deps, { orgId: 'o1', objectId: 'x', actorId: 'u1', cloudBaseUrl: 'https://cloud' }) expectError(out, 404, 'Not found') }) it('returns storage_not_found when a file has no storage row', async () => { const { deps } = makeDeps({ matter: { get: async () => file('m1') }, storages: { get: async () => null } }) - const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' }) + const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1', cloudBaseUrl: 'https://cloud' }) expectError(out, 404, 'Storage not found') }) it('meters egress then presigns the download URL for a file', async () => { vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: true }) const presignDownload = vi.fn(async () => 'https://signed') - const { deps } = makeDeps({ matter: { get: async () => file('m1', { size: 100 }) }, s3: { presignDownload } }) - const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' }) + const { deps, record } = makeDeps({ + matter: { get: async () => file('m1', { size: 100 }) }, + s3: { presignDownload }, + }) + const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1', cloudBaseUrl: 'https://cloud' }) expect(out).toMatchObject({ ok: true, downloadUrl: 'https://signed' }) expect(meterDownloadTraffic).toHaveBeenCalledWith( deps, @@ -783,13 +786,22 @@ describe('object usecase', () => { sourceId: 'm1', }), ) + expect(record).toHaveBeenCalledWith({ + orgId: 'o1', + userId: 'u1', + action: 'object_download', + targetType: 'file', + targetId: 'm1', + targetName: 'm1.txt', + metadata: expect.objectContaining({ bytes: 100, source: 'object_download', status: 'issued' }), + }) }) it('maps a quota_exceeded metering outcome and never presigns', async () => { vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: false, reason: 'quota_exceeded' }) const presignDownload = vi.fn() const { deps } = makeDeps({ matter: { get: async () => file('m1') }, s3: { presignDownload } }) - const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' }) + const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1', cloudBaseUrl: 'https://cloud' }) expectError(out, 422, 'Traffic quota exceeded', 'QUOTA_EXCEEDED') expect(presignDownload).not.toHaveBeenCalled() }) @@ -797,7 +809,7 @@ describe('object usecase', () => { it('maps an insufficient_credits metering outcome', async () => { vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: false, reason: 'insufficient_credits' }) const { deps } = makeDeps({ matter: { get: async () => file('m1') } }) - const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' }) + const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1', cloudBaseUrl: 'https://cloud' }) expectError(out, 402, 'Insufficient credits', 'INSUFFICIENT_CREDITS') expect((out as { ok: false; error: AppError }).error.meta.metadata).toEqual({ resource: 'storage_egress' }) }) @@ -814,9 +826,9 @@ describe('object usecase', () => { }, }, }) - await expect(getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' })).rejects.toThrow( - 'sign failed', - ) + await expect( + getObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1', cloudBaseUrl: 'https://cloud' }), + ).rejects.toThrow('sign failed') expect(refundTraffic).toHaveBeenCalledWith('o1', 250) }) }) diff --git a/server/usecases/object.ts b/server/usecases/object.ts index d3291016..dcbd82e7 100644 --- a/server/usecases/object.ts +++ b/server/usecases/object.ts @@ -435,9 +435,9 @@ export type GetObjectOutcome = export async function getObject( deps: Pick< Deps, - 'matter' | 'storages' | 's3' | 'quota' | 'licenseBinding' | 'licensingCloud' | 'cloudTrafficReports' + 'matter' | 'storages' | 's3' | 'quota' | 'licenseBinding' | 'licensingCloud' | 'cloudTrafficReports' | 'activity' >, - params: { orgId: string; objectId: string; cloudBaseUrl: string }, + params: { orgId: string; objectId: string; actorId: string; cloudBaseUrl: string }, ): Promise { const matter = await deps.matter.get(params.objectId, params.orgId) // Live objects only — a trashed object is fetched via GET /trash/objects/{id}. @@ -472,6 +472,22 @@ export async function getObject( await deps.quota.refundTraffic(params.orgId, bytes) throw error } + await deps.activity.record({ + orgId: params.orgId, + userId: params.actorId, + action: 'object_download', + targetType: 'file', + targetId: matter.id, + targetName: matter.name, + metadata: { + status: 'issued', + source: 'object_download', + matterId: matter.id, + storageId: matter.storageId, + bytes, + matterType: matter.type, + }, + }) return { ok: true, matter, downloadUrl } } @@ -788,6 +804,13 @@ export async function confirmUpload( targetType: 'file', targetId: confirmed.id, targetName: confirmed.name, + metadata: { + bytes, + storageId: confirmed.storageId, + matterId: confirmed.id, + matterType: confirmed.type, + onConflict: opts.onConflict ?? 'fail', + }, }) } @@ -900,7 +923,7 @@ export type SaveToDriveDeps = { } export interface SaveShareInput { - share: { id: string } + share: { id: string; creatorId: string } matter: Matter currentUserId: string targetOrgId: string @@ -1110,6 +1133,14 @@ export async function saveShareToDrive(deps: SaveToDriveDeps, input: SaveShareIn return copyMatterToOrg(deps, { ...rest, sourceMatter, - activity: { action: 'save_from_share', metadata: { sourceShareId: share.id } }, + activity: { + action: 'save_from_share', + metadata: { + sourceShareId: share.id, + sourceMatterId: sourceMatter.id, + creatorId: share.creatorId, + bytes: sourceMatter.size ?? 0, + }, + }, }) } diff --git a/server/usecases/ports/activity.ts b/server/usecases/ports/activity.ts index c169ad2e..2f09b674 100644 --- a/server/usecases/ports/activity.ts +++ b/server/usecases/ports/activity.ts @@ -2,9 +2,13 @@ // No drizzle here — the port is what usecases and http see; the adapter maps rows // into these shapes. +export type ActivityActorType = 'user' | 'anonymous' | 'system' | 'downloader' + export interface RecordActivityInput { orgId: string - userId: string + userId?: string | null + actorType?: ActivityActorType + actorRef?: string | null action: string targetType: string targetId?: string @@ -15,7 +19,9 @@ export interface RecordActivityInput { export interface ActivityEvent { id: string orgId: string - userId: string + userId: string | null + actorType: ActivityActorType + actorRef: string | null action: string targetType: string targetId: string | null @@ -25,7 +31,7 @@ export interface ActivityEvent { } export interface ActivityEventWithUser extends ActivityEvent { - user: { id: string; name: string; image: string | null } + user: { id: string | null; name: string; image: string | null } } export interface AdminAuditEventWithOrg extends ActivityEventWithUser { diff --git a/server/usecases/ports/admin-stats.ts b/server/usecases/ports/admin-stats.ts index ad7f3061..f3047bf8 100644 --- a/server/usecases/ports/admin-stats.ts +++ b/server/usecases/ports/admin-stats.ts @@ -2,6 +2,12 @@ import type { AdminBackgroundJobFailure, AdminCoreStats, AdminCountByStatus, + AdminDashboardGrowthStats, + AdminDashboardOverviewStats, + AdminDashboardRankingStats, + AdminDashboardSharingStats, + AdminDashboardStorageStats, + AdminDashboardTrafficStats, AdminDetailedStats, AdminDownloaderHealth, AdminDownloadFailureReason, @@ -39,4 +45,15 @@ export interface AdminDetailedStatsBase { export interface AdminStatsRepo { getCoreStatsBase(now: Date): Promise getDetailedStatsBase(now: Date, periodDays: number): Promise + getDashboardOverviewStats(now: Date, range: AdminStatsDateRange): Promise + getDashboardGrowthStats(now: Date, range: AdminStatsDateRange): Promise + getDashboardStorageStats(now: Date, range: AdminStatsDateRange): Promise + getDashboardTrafficStats(now: Date, range: AdminStatsDateRange): Promise + getDashboardSharingStats(now: Date, range: AdminStatsDateRange): Promise + getDashboardRankingStats(now: Date, range: AdminStatsDateRange): Promise +} + +export interface AdminStatsDateRange { + from: Date + to: Date } diff --git a/server/usecases/share.test.ts b/server/usecases/share.test.ts index f26392c3..69ebc929 100644 --- a/server/usecases/share.test.ts +++ b/server/usecases/share.test.ts @@ -563,18 +563,25 @@ describe('downloadShareObject', () => { expect.objectContaining({ orgId: 'o-1', userId: 'viewer-9', + actorType: 'user', action: 'share_download', targetId: 's-1', targetName: 'file.bin', - metadata: { anonymous: false }, + metadata: expect.objectContaining({ anonymous: false, source: 'landing_share', status: 'issued', bytes: 1024 }), }), ) }) - it('attributes anonymous downloads to the creator', async () => { + it('records anonymous downloads without attributing them to the creator', async () => { const { deps, record } = makeDeps() await downloadShareObject(deps, { ...baseParams, viewerId: null }) - expect(record).toHaveBeenCalledWith(expect.objectContaining({ userId: 'creator-1', metadata: { anonymous: true } })) + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + userId: null, + actorType: 'anonymous', + metadata: expect.objectContaining({ anonymous: true, creatorId: 'creator-1', source: 'landing_share' }), + }), + ) }) it('still succeeds when the activity record throws', async () => { diff --git a/server/usecases/share.ts b/server/usecases/share.ts index 9d97ec21..45345859 100644 --- a/server/usecases/share.ts +++ b/server/usecases/share.ts @@ -173,7 +173,31 @@ export async function viewShare(deps: ShareDeps, params: ViewShareParams): Promi // View-dedup increment: non-creators whose view cookie isn't yet 'seen'. The // handler sets the cookie when setViewCookie is true. const setViewCookie = !isCreator && viewCookie !== 'seen' - if (setViewCookie) await deps.share.incrementViews(share.id) + if (setViewCookie) { + await deps.share.incrementViews(share.id) + try { + await deps.activity.record({ + orgId: share.orgId, + userId: viewerId, + actorType: viewerId ? 'user' : 'anonymous', + action: 'share_view', + targetType: 'share', + targetId: share.id, + targetName: matter.name, + metadata: { + shareId: share.id, + matterId: matter.id, + creatorId: share.creatorId, + kind: share.kind, + requiresPassword: !!share.passwordHash, + matterType: matter.type, + bytes: matter.size ?? 0, + }, + }) + } catch (error) { + console.error('[shares] recordActivity failed:', error) + } + } const dto = await composeShareView(deps, { share, matter, recipients }, { viewerId, accessCookie, now }) return { ok: true, setViewCookie, dto } @@ -181,7 +205,7 @@ export async function viewShare(deps: ShareDeps, params: ViewShareParams): Promi // ─── POST /:token/sessions — verify password → access-cookie decision ──────── -export type VerifySharePasswordParams = { token: string; password: string; now?: Date } +export type VerifySharePasswordParams = { token: string; password: string; viewerId?: string | null; now?: Date } export type VerifySharePasswordOutcome = { ok: true; setAccessCookieExpiry: Date } | { ok: false; error: AppError } @@ -189,12 +213,12 @@ export async function verifySharePassword( deps: ShareDeps, params: VerifySharePasswordParams, ): Promise { - const { token, password, now = new Date() } = params + const { token, password, viewerId = null, now = new Date() } = params const resolved = await deps.share.resolveByToken(token) if (resolved.status !== 'ok') return { ok: false, error: notFound('Share not found or revoked') } - const { share } = resolved + const { share, matter } = resolved if (share.kind !== 'landing') return { ok: false, error: notFound('Share not found or revoked') } if (!share.passwordHash || !verifyPasswordHash(share.passwordHash, password)) @@ -206,6 +230,26 @@ export async function verifySharePassword( ? new Date(Math.min(share.expiresAt.getTime(), now.getTime() + oneDayMs)) : new Date(now.getTime() + oneDayMs) + try { + await deps.activity.record({ + orgId: share.orgId, + userId: viewerId, + actorType: viewerId ? 'user' : 'anonymous', + action: 'share_password_passed', + targetType: 'share', + targetId: share.id, + targetName: matter.name, + metadata: { + shareId: share.id, + matterId: matter.id, + creatorId: share.creatorId, + kind: share.kind, + }, + }) + } catch (error) { + console.error('[shares] recordActivity failed:', error) + } + return { ok: true, setAccessCookieExpiry } } @@ -364,19 +408,29 @@ export async function downloadShareObject( throw e } - // Record download audit event. Use the authenticated viewer if available; - // fall back to the share creator as the org-attributed actor for anonymous - // downloads. The presigned URL is never stored in metadata. - const actorId = viewerId ?? share.creatorId + // Record the real actor. Anonymous downloads belong to the share target, not + // to the share creator as a fake user action. The presigned URL is never stored. try { await deps.activity.record({ orgId: share.orgId, - userId: actorId, + userId: viewerId, + actorType: viewerId ? 'user' : 'anonymous', action: 'share_download', targetType: 'share', targetId: share.id, targetName: targetMatter.name, - metadata: { anonymous: !viewerId }, + metadata: { + anonymous: !viewerId, + status: 'issued', + source: 'landing_share', + shareId: share.id, + matterId: targetMatter.id, + rootMatterId: matter.id, + creatorId: share.creatorId, + storageId: targetMatter.storageId, + bytes, + kind: share.kind, + }, }) } catch (error) { console.error('[shares] recordActivity failed:', error) diff --git a/shared/types/admin-stats.ts b/shared/types/admin-stats.ts index 5762e60c..0b0fd02e 100644 --- a/shared/types/admin-stats.ts +++ b/shared/types/admin-stats.ts @@ -145,3 +145,106 @@ export interface AdminDetailedStats { } } } + +export interface AdminStatsRange { + generatedAt: string + from: string + to: string +} + +export interface AdminStatsDelta { + value: number + previousValue: number + changePercent: number +} + +export interface AdminDashboardOverviewStats extends AdminStatsRange { + totals: { + users: number + newUsers: AdminStatsDelta + activeUsers: AdminStatsDelta + storageUsedBytes: number + storageQuotaBytes: number + trafficBytes: AdminStatsDelta + uploadBytes: AdminStatsDelta + downloadBytes: AdminStatsDelta + activeShares: number + shareViews: AdminStatsDelta + shareDownloads: AdminStatsDelta + } + trends: Array<{ + date: string + newUsers: number + activeUsers: number + storageUsedBytes: number + uploadBytes: number + downloadBytes: number + }> +} + +export interface AdminDashboardGrowthStats extends AdminStatsRange { + summary: { + totalUsers: number + newUsers: AdminStatsDelta + activeUsers: AdminStatsDelta + verifiedUsers: number + bannedUsers: number + silentUsers: number + } + userScaleTrend: Array<{ date: string; newUsers: number; totalUsers: number }> + activeUserTrend: Array<{ date: string; dau: number; wau: number; mau: number }> + userStatus: Array<{ name: string; value: number; percent: number }> + registrationSources: Array<{ name: string; value: number; percent: number }> +} + +export interface AdminDashboardStorageStats extends AdminStatsRange { + summary: { + storageUsedBytes: number + quotaBytes: number + fileCount: number + newFiles: AdminStatsDelta + newBytes: AdminStatsDelta + coldFileBytes: number + } + storageTrend: Array<{ date: string; usedBytes: number; newBytes: number; newFiles: number }> + typeBreakdown: Array<{ type: string; bytes: number; files: number; percent: number }> + sizeBreakdown: Array<{ name: string; bytes: number; files: number; percent: number }> + ageBreakdown: Array<{ name: string; bytes: number; files: number; percent: number }> +} + +export interface AdminDashboardTrafficStats extends AdminStatsRange { + summary: { + totalBytes: AdminStatsDelta + requestCount: AdminStatsDelta + issuedDownloads: number + blockedDownloads: number + issueRate: number + peakDailyBytes: number + } + trafficTrend: Array<{ date: string; uploadBytes: number; downloadBytes: number; requests: number }> + sourceBreakdown: Array<{ name: string; bytes: number; requests: number; percent: number }> + issueStatus: Array<{ status: string; count: number; percent: number }> + bandwidthTrend: Array<{ date: string; bytes: number }> +} + +export interface AdminDashboardSharingStats extends AdminStatsRange { + summary: { + activeShares: number + createdShares: AdminStatsDelta + views: AdminStatsDelta + downloads: AdminStatsDelta + saves: AdminStatsDelta + downloadConversionRate: number + } + funnel: Array<{ name: string; value: number; percent: number }> + trend: Array<{ date: string; views: number; downloads: number; saves: number }> + typeBreakdown: Array<{ name: string; value: number; percent: number }> + sourceBreakdown: Array<{ name: string; value: number; percent: number }> + topShares: Array +} + +export interface AdminDashboardRankingStats extends AdminStatsRange { + topShares: Array + topSpaces: AdminUsageBySpace[] + storageByType: AdminStorageByType[] +} diff --git a/shared/types/index.ts b/shared/types/index.ts index 0ae829ef..dbc19290 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -213,10 +213,18 @@ export type { AdminBackgroundJobFailure, AdminCoreStats, AdminCountByStatus, + AdminDashboardGrowthStats, + AdminDashboardOverviewStats, + AdminDashboardRankingStats, + AdminDashboardSharingStats, + AdminDashboardStorageStats, + AdminDashboardTrafficStats, AdminDetailedStats, AdminDownloaderHealth, AdminDownloadFailureReason, + AdminStatsDelta, AdminStatsPoint, + AdminStatsRange, AdminStorageByType, AdminTopShare, AdminUsageBySpace, @@ -532,10 +540,14 @@ export interface Announcement { updatedAt: string } +export type ActivityActorType = 'user' | 'anonymous' | 'system' | 'downloader' + export interface ActivityEvent { id: string orgId: string - userId: string + userId: string | null + actorType: ActivityActorType + actorRef: string | null action: string targetType: string targetId: string | null @@ -543,7 +555,7 @@ export interface ActivityEvent { metadata: string | null createdAt: string user: { - id: string + id: string | null name: string image: string | null } diff --git a/src/components/admin/audit-activity-feed.tsx b/src/components/admin/audit-activity-feed.tsx index 3612af43..f52bac5f 100644 --- a/src/components/admin/audit-activity-feed.tsx +++ b/src/components/admin/audit-activity-feed.tsx @@ -64,12 +64,13 @@ function AdminAuditActivityItem({ event }: { event: AdminAuditEvent }) { ? Object.entries(metadata).filter(([key, value]) => !['status', 'result', 'from', 'to'].includes(key) && value) : [] const targetName = event.targetName || event.targetId || event.targetType + const actorLabel = event.user.name || formatActor(event) return (
{event.user.image && } - {getInitials(event.user.name || event.userId)} + {getInitials(actorLabel)}
@@ -108,6 +109,14 @@ function AdminAuditActivityItem({ event }: { event: AdminAuditEvent }) { ) } +function formatActor(event: AdminAuditEvent): string { + if (event.userId) return event.userId + if (event.actorType === 'anonymous') return 'Anonymous' + if (event.actorType === 'system') return event.actorRef ? `System:${event.actorRef}` : 'System' + if (event.actorType === 'downloader') return event.actorRef ? `Downloader:${event.actorRef}` : 'Downloader' + return 'Unknown' +} + function parseActivityMetadata(metadata: string | null): Record | null { if (!metadata) return null diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 00000000..9a872521 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,158 @@ +import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react' +import * as React from 'react' +import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker' +import { Button, buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = 'label', + buttonVariant = 'ghost', + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps['variant'] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className, + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }), + ...formatters, + }} + classNames={{ + root: cn('w-fit', defaultClassNames.root), + months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months), + month: cn('flex w-full flex-col gap-4', defaultClassNames.month), + nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50', + defaultClassNames.button_previous, + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50', + defaultClassNames.button_next, + ), + month_caption: cn( + 'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)', + defaultClassNames.month_caption, + ), + dropdowns: cn( + 'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium', + defaultClassNames.dropdowns, + ), + dropdown_root: cn( + 'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50', + defaultClassNames.dropdown_root, + ), + dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown), + caption_label: cn( + 'font-medium select-none', + captionLayout === 'label' + ? 'text-sm' + : 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground', + defaultClassNames.caption_label, + ), + month_grid: cn('w-full border-collapse', defaultClassNames.month_grid), + weekdays: cn('flex', defaultClassNames.weekdays), + weekday: cn( + 'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none', + defaultClassNames.weekday, + ), + week: cn('mt-2 flex w-full', defaultClassNames.week), + week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header), + week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number), + day: cn( + 'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md', + props.showWeekNumber + ? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-md' + : '[&:first-child[data-selected=true]_button]:rounded-l-md', + defaultClassNames.day, + ), + range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start), + range_middle: cn('rounded-none', defaultClassNames.range_middle), + range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end), + today: cn( + 'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none', + defaultClassNames.today, + ), + outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside), + disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled), + hidden: cn('invisible', defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return
+ }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === 'left') { + return + } + + if (orientation === 'right') { + return + } + + return + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
{children}
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + - ))} -
+ {SECTION_ORDER.filter((section) => section !== 'overview').map((section) => ( + updateRange(section, range)} + onToggle={() => toggleSection(section)} + > + {!hasAnalytics ? ( + + ) : section === 'growth' ? ( + {(data) => } + ) : section === 'storage' ? ( + {(data) => } + ) : section === 'traffic' ? ( + {(data) => } + ) : section === 'sharing' ? ( + {(data) => } + ) : ( + {(data) => } )} -
- - {entitlementLoading && } - {!entitlementLoading && !hasAnalytics && ( - - )} - {!entitlementLoading && hasAnalytics && detailsQuery.isLoading && } - {!entitlementLoading && hasAnalytics && detailsQuery.isError && ( - - - {t('admin.overview.analyticsErrorTitle')} - {t('admin.overview.analyticsErrorDescription')} - - - )} - {!entitlementLoading && hasAnalytics && detailsQuery.data && } - -
- ) -} - -function CoreMetricGrid({ core }: { core: AdminCoreStats }) { - const { t } = useTranslation() - const metrics = [ - { - title: t('admin.overview.metrics.users'), - value: formatNumber(core.users.total), - detail: t('admin.overview.metrics.usersDetail', { - active: formatNumber(core.users.activeLast30Days), - newUsers: formatNumber(core.users.newLast7Days), - admins: formatNumber(core.users.admins), - }), - icon: Users, - }, - { - title: t('admin.overview.metrics.activeUsers'), - value: formatNumber(core.users.activeLast30Days), - detail: t('admin.overview.metrics.activeUsersDetail', { - percent: formatPercentRatio(core.users.activeLast30Days, core.users.total), - }), - icon: Activity, - }, - { - title: t('admin.overview.metrics.spaces'), - value: formatNumber(core.spaces.total), - detail: t('admin.overview.metrics.spacesDetail', { - team: formatNumber(core.spaces.team), - personal: formatNumber(core.spaces.personal), - newSpaces: formatNumber(core.spaces.newLast30Days), - }), - icon: Workflow, - }, - { - title: t('admin.overview.metrics.storage'), - value: formatSize(core.storage.usedBytes), - detail: t('admin.overview.metrics.storageDetail', { - percent: formatPercent(core.storage.quotaUtilization), - quota: formatQuota(core.storage.usedBytes, core.storage.quotaBytes), - }), - icon: Database, - }, - { - title: t('admin.overview.metrics.traffic'), - value: formatSize(core.traffic.usedBytes), - detail: t('admin.overview.metrics.trafficDetail', { - usage: formatQuota(core.traffic.usedBytes, core.traffic.quotaBytes), - period: core.traffic.period, - }), - icon: Cloud, - }, - { - title: t('admin.overview.metrics.shares'), - value: formatNumber(core.sharing.activeShares), - detail: t('admin.overview.metrics.sharesDetail', { - total: formatNumber(core.sharing.totalShares), - views: formatNumber(core.sharing.views), - }), - icon: Link2, - }, - ] - - return ( -
- {metrics.map((metric) => ( - + ))}
) } -function CoreMetricCard({ +function useDashboardSectionQuery( + section: SectionId, + range: DateRange, + openSections: Set, + enabled: boolean, + queryFn: (filter: AdminStatsRangeFilter) => Promise, +) { + return useQuery({ + queryKey: ['admin', 'dashboard', section, rangeKey(range)], + queryFn: () => queryFn(toRangeFilter(range)), + enabled: enabled && openSections.has(section), + staleTime: 30_000, + }) +} + +function DashboardSection({ title, - value, - detail, - icon: Icon, + description, + open, + locked, + range, + children, + onToggle, + onRangeChange, }: { + id: SectionId title: string - value: string - detail: string - icon: LucideIcon + description: string + open: boolean + locked?: boolean + range: DateRange + children: ReactNode + onToggle: () => void + onRangeChange: (range: DateRange) => void }) { return ( - - - {title} - - - -
{value}
-

- {detail} -

-
-
- ) -} - -function CapacityPostureCard({ core }: { core: AdminCoreStats }) { - const { t } = useTranslation() - - return ( - - - {t('admin.overview.capacityTitle')} - {t('admin.overview.capacityDescription')} - - - - - - - - - ) -} - -function OperatingMixCard({ core }: { core: AdminCoreStats }) { - const { t } = useTranslation() - const shareDownloadRate = formatPercentRatio(core.sharing.downloads, core.sharing.views) - - return ( - - - {t('admin.overview.mixTitle')} - {t('admin.overview.mixDescription')} - - - - - - - - - ) -} - -function DetailedDashboard({ stats }: { stats: AdminDetailedStats }) { - return ( -
- - -
- - +
+
+ + {open && !locked && }
-
- - -
-
- - -
-
+ {open &&
{children}
} + ) } -function PeriodSummaryGrid({ stats }: { stats: AdminDetailedStats }) { - const { t } = useTranslation() - const totals = summarizeTrends(stats) - const topSpacePressure = stats.usageBySpace.reduce((max, space) => Math.max(max, space.utilization), 0) +function DateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) { + const presets = dateRangePresets() + return ( + + + + + +
+
+ {presets.map((preset) => ( + + ))} +
+ range && onChange(range)} numberOfMonths={2} /> +
+
+
+ ) +} + +function OverviewSection({ stats }: { stats: AdminDashboardOverviewStats }) { const cards = [ { - label: t('admin.overview.periodKpi.signups'), - value: formatNumber(totals.signups), - detail: t('admin.overview.periodKpi.periodDays', { days: stats.periodDays }), + label: '注册用户', + value: formatNumber(stats.totals.users), + delta: formatDelta(stats.totals.newUsers), + icon: Users, + metrics: [ + { label: '新增用户', value: formatNumber(stats.totals.newUsers.value) }, + { label: '活跃用户', value: formatNumber(stats.totals.activeUsers.value) }, + ], }, { - label: t('admin.overview.periodKpi.shareViews'), - value: formatNumber(totals.shareViews), - detail: t('admin.overview.periodKpi.shareDownloads', { downloads: formatNumber(totals.shareDownloads) }), + label: '活跃用户', + value: formatNumber(stats.totals.activeUsers.value), + delta: formatDelta(stats.totals.activeUsers), + icon: TrendingUp, + metrics: [ + { label: '活跃率', value: formatPercent(ratio(stats.totals.activeUsers.value, stats.totals.users)) }, + { label: '环比人数', value: formatNumber(stats.totals.activeUsers.previousValue) }, + ], }, { - label: t('admin.overview.periodKpi.conversion'), - value: formatPercent(stats.sharing.conversionRate), - detail: t('admin.overview.periodKpi.conversionDetail'), + label: '存储占用', + value: formatSize(stats.totals.storageUsedBytes), + delta: formatDelta(stats.totals.uploadBytes, formatSize), + icon: Database, + metrics: [ + { label: '使用率', value: formatPercent(ratio(stats.totals.storageUsedBytes, stats.totals.storageQuotaBytes)) }, + { label: '区间新增', value: formatSize(stats.totals.uploadBytes.value) }, + ], }, { - label: t('admin.overview.periodKpi.remoteSuccess'), - value: formatPercent(stats.remoteDownloads.successRate), - detail: t('admin.overview.remoteCompleted', { - completed: formatNumber(stats.remoteDownloads.completed), - total: formatNumber(stats.remoteDownloads.total), - }), - }, - { - label: t('admin.overview.periodKpi.jobFailures'), - value: formatNumber(stats.reliability.backgroundJobs.failed), - detail: t('admin.overview.jobFailures', { - failed: formatNumber(stats.reliability.backgroundJobs.failed), - total: formatNumber(stats.reliability.backgroundJobs.total), - }), - }, - { - label: t('admin.overview.periodKpi.topSpacePressure'), - value: formatPercent(topSpacePressure), - detail: t('admin.overview.periodKpi.topSpacePressureDetail'), + label: '流量统计', + value: formatSize(stats.totals.trafficBytes.value), + delta: formatDelta(stats.totals.trafficBytes, formatSize), + icon: Network, + metrics: [ + { label: '上传', value: formatSize(stats.totals.uploadBytes.value) }, + { label: '下载', value: formatSize(stats.totals.downloadBytes.value) }, + ], }, ] return ( -
- {cards.map((card) => ( - +
+
+ {cards.map((card) => ( + + ))} +
+ + + + + + + + + + + + + + + + +
+ ) +} + +function GrowthSection({ stats }: { stats: AdminDashboardGrowthStats }) { + return ( +
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} + +function StorageSection({ stats }: { stats: AdminDashboardStorageStats }) { + return ( +
+
+ + + + +
+ + + + + + + + + + + + + + + + ({ name: row.type, value: row.bytes, percent: row.percent }))} + rightTitle="文件大小结构" + rightData={stats.sizeBreakdown.map((row) => ({ name: row.name, value: row.bytes, percent: row.percent }))} + valueFormatter={formatSize} + /> + ({ + name: row.name, + value: row.bytes, + percent: row.percent, + detail: `${formatNumber(row.files)} files`, + }))} + valueFormatter={formatSize} + /> +
+ ) +} + +function TrafficSection({ stats }: { stats: AdminDashboardTrafficStats }) { + return ( +
+
+ + + + +
+ + + + + + + + + + + + + + + + ({ name: row.name, value: row.bytes, percent: row.percent }))} + rightTitle="签发状态分布" + rightData={stats.issueStatus.map((row) => ({ name: row.status, value: row.count, percent: row.percent }))} + valueFormatter={formatSize} + /> +
+ ) +} + +function SharingSection({ stats }: { stats: AdminDashboardSharingStats }) { + return ( +
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + +
+ ) +} + +function RankingSection({ stats }: { stats: AdminDashboardRankingStats }) { + return ( +
+ + +
+ {stats.topSpaces.map((space) => ( +
+
+ {space.orgName} + {formatSize(space.usedBytes)} +
+ +

+ {space.orgType} · {formatPercent(space.utilization)} +

+
+ ))} +
+
+ ({ + name: row.type, + value: row.bytes, + detail: `${formatNumber(row.files)} files`, + }))} + valueFormatter={formatSize} + /> +
+ ) +} + +function StatCard({ + label, + value, + delta, + icon: Icon, + metrics = [], +}: { + label: string + value: string + delta?: string + icon: typeof Users + metrics?: Array<{ label: string; value: string }> +}) { + return ( + + +
+
+

{label}

+

{value}

+
+ +
+
+ {metrics.slice(0, 2).map((metric) => ( +
+

{metric.label}

+

{metric.value}

+
+ ))} + {delta && ( +
+

环比

+

{delta}

+
+ )} +
+
+
+ ) +} + +function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) { + return ( + + + {title} + {subtitle &&

{subtitle}

} +
+ {children} +
+ ) +} + +function BreakdownGrid({ + leftTitle, + leftData, + rightTitle, + rightData, + valueFormatter = formatNumber, +}: { + leftTitle: string + leftData: Array<{ name: string; value: number; percent: number }> + rightTitle: string + rightData: Array<{ name: string; value: number; percent: number }> + valueFormatter?: (value: number) => string +}) { + return ( +
+ + +
+ ) +} + +function BreakdownChart({ + title, + rows, + valueFormatter, +}: { + title: string + rows: Array<{ name: string; value: number; percent: number }> + valueFormatter: (value: number) => string +}) { + return ( + +
+
+ + + + {rows.map((row, index) => ( + + ))} + + valueFormatter(Number(value))} /> + + +
+ +
+
+ ) +} + +function BreakdownTable({ + title, + rows, + valueFormatter, +}: { + title: string + rows: Array<{ name: string; value: number; percent?: number; detail?: string }> + valueFormatter: (value: number) => string +}) { + return ( + + + + ) +} + +function BreakdownRows({ + rows, + valueFormatter, +}: { + rows: Array<{ name: string; value: number; percent?: number; detail?: string }> + valueFormatter: (value: number) => string +}) { + if (rows.length === 0) return + return ( +
+ {rows.map((row, index) => ( +
+
+
+ + {labelize(row.name)} +
+ {valueFormatter(row.value)} +
+ {row.percent !== undefined && } +

+ {row.detail ?? (row.percent !== undefined ? `${formatPercent(row.percent)} of total` : '')} +

+
))}
) } -function AnalyticsKpiCard({ label, value, detail }: { label: string; value: string; detail: string }) { - return ( -
-

{label}

-

{value}

-

- {detail} -

-
- ) +function FunnelChart({ data }: { data: Array<{ name: string; value: number; percent: number }> }) { + const ref = useRef(null) + useEffect(() => { + if (!ref.current) return + const chart = echarts.init(ref.current) + chart.setOption({ + tooltip: { + trigger: 'item', + formatter: ({ name, value }: { name: string; value: number }) => `${labelize(name)}: ${formatNumber(value)}`, + }, + series: [ + { + type: 'funnel', + left: '4%', + top: 12, + bottom: 12, + width: '92%', + minSize: '24%', + maxSize: '96%', + sort: 'none', + gap: 4, + label: { + formatter: ({ name, value }: { name: string; value: number }) => + `${labelize(name)} ${formatNumber(value)}`, + }, + itemStyle: { borderColor: 'var(--color-card)', borderWidth: 1 }, + data: data.map((item, index) => ({ + name: item.name, + value: item.value, + itemStyle: { color: CHART_COLORS[index % CHART_COLORS.length] }, + })), + }, + ], + }) + const onResize = () => chart.resize() + window.addEventListener('resize', onResize) + return () => { + window.removeEventListener('resize', onResize) + chart.dispose() + } + }, [data]) + return
} -function ActivityTrendCard({ stats }: { stats: AdminDetailedStats }) { - const { t } = useTranslation() - const hasData = stats.trends.some( - (point) => - point.signups > 0 || - point.activeUsers > 0 || - point.shareViews > 0 || - point.shareDownloads > 0 || - point.remoteTasks > 0 || - point.failedJobs > 0, - ) - const data = stats.trends.map((point) => ({ ...point, label: formatChartDate(point.date) })) - +function TopSharesTable({ rows }: { rows: AdminDashboardSharingStats['topShares'] }) { return ( - - - {t('admin.overview.charts.activityTitle')} - {t('admin.overview.charts.activityDescription')} - - - {!hasData ? ( - - ) : ( -
- - - - - - - String(label)} /> - - - - - - - - -
- )} -
-
- ) -} - -function StorageByTypeCard({ stats }: { stats: AdminDetailedStats }) { - const { t } = useTranslation() - const data = stats.storageByType.map((item) => ({ ...item, label: formatStorageType(item.type) })) - - return ( - - - {t('admin.overview.charts.storageByTypeTitle')} - {t('admin.overview.charts.storageByTypeDescription')} - - - {data.length === 0 ? ( - - ) : ( -
- - - - - - formatSize(Number(value))} /> - - {data.map((item, index) => ( - - ))} - - - -
- )} -
-
- ) -} - -function UsageBySpaceCard({ stats }: { stats: AdminDetailedStats }) { - const { t } = useTranslation() - - return ( - - - {t('admin.overview.usageBySpaceTitle')} - {t('admin.overview.usageBySpaceDescription')} - - - {stats.usageBySpace.length === 0 ? ( - - ) : ( - stats.usageBySpace.map((space) => ( -
-
-
-

{space.orgName}

-

{space.orgType}

-
- {formatPercent(space.utilization)} -
- -

{formatQuota(space.usedBytes, space.quotaBytes)}

-
- )) - )} -
-
- ) -} - -function SharePerformanceCard({ stats }: { stats: AdminDetailedStats }) { - const { t } = useTranslation() - - return ( - - - {t('admin.overview.sharePerformanceTitle')} - {t('admin.overview.sharePerformanceDescription')} - - - - - - - - ) -} - -function TopSharesCard({ stats }: { stats: AdminDetailedStats }) { - const { t } = useTranslation() - - return ( - - - {t('admin.overview.topSharesTitle')} - {t('admin.overview.topSharesDescription')} - - - {stats.topShares.length === 0 ? ( - - ) : ( - - - - {t('admin.overview.table.share')} - {t('admin.overview.table.creator')} - {t('admin.overview.table.views')} - {t('admin.overview.table.downloads')} - - - - {stats.topShares.map((share) => ( - - - {share.name} - - - {share.creatorName} - - {formatNumber(share.views)} - {formatNumber(share.downloads)} - - ))} - -
- )} -
-
- ) -} - -function RemoteDownloadsCard({ stats }: { stats: AdminDetailedStats }) { - const { t } = useTranslation() - - return ( - - - {t('admin.overview.remoteDownloadsTitle')} - {t('admin.overview.remoteDownloadsDescription')} - - -
- - -
- -
-

{t('admin.overview.failureReasons')}

- {stats.remoteDownloads.failureReasons.length === 0 ? ( -

{t('admin.overview.noFailures')}

- ) : ( - stats.remoteDownloads.failureReasons.map((item) => ( -
- {item.reason} - {formatNumber(item.count)} -
- )) - )} -
-
-
- ) -} - -function ReliabilityCard({ stats }: { stats: AdminDetailedStats }) { - const { t } = useTranslation() - const license = stats.reliability.license - - return ( - - - {t('admin.overview.reliabilityTitle')} - {t('admin.overview.reliabilityDescription')} - - -
- - -
- -
- - -
-
-

{t('admin.overview.recentJobFailures')}

- {stats.reliability.backgroundJobs.failures.length === 0 ? ( -

{t('admin.overview.noFailures')}

- ) : ( - stats.reliability.backgroundJobs.failures.map((job) => ( -
-
- {job.type} - {formatDate(job.createdAt)} -
-

- {job.errorMessage ?? t('admin.overview.noErrorMessage')} -

-
- )) - )} -
-
-
- ) -} - -function MetricRow({ - label, - value, - detail, - percent, -}: { - label: string - value: string - detail: string - percent: number -}) { - return ( -
-
-
-

{label}

-

- {detail} -

-
- {value} -
- -
- ) -} - -function StatusList({ title, items }: { title: string; items: Array<{ status: string; count: number }> }) { - const { t } = useTranslation() - - return ( -
-

{title}

- {items.length === 0 ? ( -

{t('admin.overview.emptyTable')}

+ + {rows.length === 0 ? ( + ) : ( - items.map((item) => ( -
- {item.status} - {formatNumber(item.count)} -
- )) + + + + 分享 + 创建者 + 访问 + 下载 + 占比 + + + + {rows.map((row) => ( + + + {row.name} + + + {row.creatorName} + + {formatNumber(row.views)} + {formatNumber(row.downloads)} + {formatPercent(row.viewPercent)} + + ))} + +
)} -
+ ) } -function CapacityMeter({ label, value, description }: { label: string; value: number; description: string }) { - return ( -
-
- {label} - {formatPercent(value)} -
- -

- {description} -

-
- ) -} - -function SummaryTile({ - icon: Icon, - label, - value, - detail, +function QueryState({ + query, + children, }: { - icon: LucideIcon - label: string - value: string - detail: string + query: { isLoading: boolean; isError: boolean; data: T | undefined } + children: (data: T) => ReactNode }) { - return ( -
-
- - {label} + if (query.isLoading) return + if (query.isError) + return ( +
+ 统计数据加载失败。
-

{value}

-

- {detail} -

-
- ) + ) + if (!query.data) return + return <>{children(query.data)} } -function OverviewSkeleton() { +function SectionSkeleton() { return ( -
-
- - -
-
- {SKELETON_METRICS.map((metric) => ( - +
+
+ {[1, 2, 3, 4].map((item) => ( + ))}
-
- - -
+
) } -function DetailedSkeleton() { +function EmptyState() { return ( -
-
- {SKELETON_METRICS.map((metric) => ( - - ))} -
- -
- - -
-
+
暂无统计数据
) } -function EmptyState({ label }: { label: string }) { - return ( -
{label}
- ) +function initialRanges(today: Date): Record { + const last30 = { from: startOfDay(subDays(today, 29)), to: endOfDay(today) } + return { + overview: last30, + growth: last30, + storage: { from: startOfDay(subDays(today, 89)), to: endOfDay(today) }, + traffic: { from: startOfDay(subDays(today, 6)), to: endOfDay(today) }, + sharing: last30, + ranking: last30, + } } -function summarizeTrends(stats: AdminDetailedStats) { - return stats.trends.reduce( - (totals, point) => ({ - signups: totals.signups + point.signups, - shareViews: totals.shareViews + point.shareViews, - shareDownloads: totals.shareDownloads + point.shareDownloads, - remoteTasks: totals.remoteTasks + point.remoteTasks, - failedJobs: totals.failedJobs + point.failedJobs, - }), - { signups: 0, shareViews: 0, shareDownloads: 0, remoteTasks: 0, failedJobs: 0 }, - ) +function dateRangePresets(): Array<{ label: string; range: DateRange }> { + const today = new Date() + return [ + { label: '最近 3 天', range: { from: startOfDay(subDays(today, 2)), to: endOfDay(today) } }, + { label: '最近 7 天', range: { from: startOfDay(subDays(today, 6)), to: endOfDay(today) } }, + { label: '最近 30 天', range: { from: startOfDay(subDays(today, 29)), to: endOfDay(today) } }, + { label: '最近一个月', range: { from: startOfDay(subDays(today, 30)), to: endOfDay(today) } }, + { label: '本月', range: { from: startOfMonth(today), to: endOfDay(today) } }, + { label: '上月', range: { from: startOfMonth(subMonths(today, 1)), to: endOfMonth(subMonths(today, 1)) } }, + ] +} + +function toRangeFilter(range: DateRange): AdminStatsRangeFilter { + return { + ...(range.from ? { from: startOfDay(range.from).toISOString() } : {}), + ...(range.to ? { to: endOfDay(range.to).toISOString() } : {}), + } +} + +function rangeKey(range: DateRange): string { + return `${range.from?.toISOString() ?? ''}:${range.to?.toISOString() ?? ''}` +} + +function formatRange(range: DateRange): string { + if (!range.from) return '选择时间范围' + if (!range.to) return format(range.from, 'yyyy-MM-dd') + return `${format(range.from, 'yyyy-MM-dd')} - ${format(range.to, 'yyyy-MM-dd')}` +} + +function sectionTitle(section: SectionId): string { + if (section === 'growth') return '用户与增长' + if (section === 'storage') return '存储与文件' + if (section === 'traffic') return '流量统计' + if (section === 'sharing') return '分享与访问' + return '排行与明细' +} + +function sectionDescription(section: SectionId): string { + if (section === 'growth') return '注册、活跃、沉默用户和增长来源。' + if (section === 'storage') return '容量增长、文件结构、冷文件和空间压力。' + if (section === 'traffic') return '上传确认、下载签发、计费流量和请求放行情况。' + if (section === 'sharing') return '分享访问、下载签发、转存和 Top 分享。' + return '用于站长定位高占用空间、高访问分享和主要文件类型。' } function formatNumber(value: number): string { @@ -946,33 +1179,20 @@ function formatNumber(value: number): string { } function formatPercent(value: number): string { - return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value)}%` + return `${Math.round(value * 10) / 10}%` } -function formatPercentRatio(numerator: number, denominator: number): string { - return formatPercent(percentage(numerator, denominator)) +function ratio(part: number, total: number): number { + if (total <= 0) return 0 + return (part / total) * 100 } -function percentage(numerator: number, denominator: number): number { - if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) return 0 - return (numerator / denominator) * 100 -} - -function formatQuota(used: number, total: number): string { - if (total <= 0) return formatSize(used) - return `${formatSize(used)} / ${formatSize(total)}` -} - -function formatChartDate(value: string): string { - const date = new Date(`${value}T00:00:00.000Z`) - if (Number.isNaN(date.getTime())) return value - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) -} - -function formatStorageType(value: string): string { - if (!value) return 'unknown' - if (!value.includes('/')) return value - return value.split('/').at(-1) || value +function formatDelta( + delta: { value: number; previousValue: number; changePercent: number }, + valueFormatter: (value: number) => string = formatNumber, +): string { + const sign = delta.value >= delta.previousValue ? '+' : '' + return `${sign}${valueFormatter(delta.value - delta.previousValue)} (${formatPercent(delta.changePercent)})` } function formatCompactNumber(value: number): string { @@ -980,15 +1200,43 @@ function formatCompactNumber(value: number): string { } function formatCompactSize(value: number): string { - if (value < 1024) return formatNumber(value) - return formatSize(value) + if (value < 1024) return `${value} B` + return formatSize(value).replace(' ', '') } -function formatTooltipValue(value: unknown, name: unknown) { - return [formatNumber(Number(value ?? 0)), String(name)] +function chartTooltipFormatter(value: unknown, name: unknown) { + const numeric = Number(value) + const label = String(name) + const formatted = label.includes('容量') || label.includes('流量') ? formatSize(numeric) : formatNumber(numeric) + return [formatted, label] } function clampPercent(value: number): number { if (!Number.isFinite(value)) return 0 - return Math.max(0, Math.min(100, value)) + return Math.min(100, Math.max(0, value)) +} + +function labelize(value: string): string { + const labels: Record = { + normal: '正常用户', + unverified: '未验证', + banned: '已禁用', + silent: '沉默用户', + direct: '直接注册', + credential: '账号密码', + landing: '落地页分享', + direct_share: '直链分享', + views: '访问分享', + password_passed: '密码通过', + downloads: '下载签发', + saved_to_drive: '转存到网盘', + landing_share: '分享下载', + object_download: '文件下载', + webdav_download: 'WebDAV', + upload: '上传确认', + reported: '已上报', + pending: '待上报', + failed: '失败', + } + return labels[value] ?? value } diff --git a/src/routes/_authenticated/admin/teams/$orgId.test.tsx b/src/routes/_authenticated/admin/teams/$orgId.test.tsx index 01410d78..87ddc60e 100644 --- a/src/routes/_authenticated/admin/teams/$orgId.test.tsx +++ b/src/routes/_authenticated/admin/teams/$orgId.test.tsx @@ -162,6 +162,8 @@ function auditEvent(overrides: Partial = {}): AdminAuditEvent { orgId: 'route-org-1', orgName: 'Acme Team', userId: 'user-1', + actorType: 'user', + actorRef: null, action: 'upload', targetType: 'file', targetId: 'file-1', diff --git a/src/routes/_authenticated/admin/users/$userId.test.tsx b/src/routes/_authenticated/admin/users/$userId.test.tsx index 76844a89..e7ed6369 100644 --- a/src/routes/_authenticated/admin/users/$userId.test.tsx +++ b/src/routes/_authenticated/admin/users/$userId.test.tsx @@ -150,6 +150,8 @@ function auditEvent(overrides: Partial = {}): AdminAuditEvent { orgId: 'org-1', orgName: 'Personal', userId: 'route-user-1', + actorType: 'user', + actorRef: null, action: 'upload', targetType: 'file', targetId: 'file-1', diff --git a/src/routes/_authenticated/teams/$teamId/activity.tsx b/src/routes/_authenticated/teams/$teamId/activity.tsx index f89adbae..d7b2b981 100644 --- a/src/routes/_authenticated/teams/$teamId/activity.tsx +++ b/src/routes/_authenticated/teams/$teamId/activity.tsx @@ -59,17 +59,17 @@ function ActivityItem({ event }: { event: ActivityEvent }) { } const createdAt = new Date(event.createdAt as unknown as string | number) + const actorLabel = event.user.name || formatActor(event) return (
{event.user.image && } - {userInitials(event.user.name || '?')} + {userInitials(actorLabel)}

- {event.user.name || event.userId}{' '} - {detail} + {actorLabel} {detail}

{relativeTime(createdAt)}

@@ -77,6 +77,14 @@ function ActivityItem({ event }: { event: ActivityEvent }) { ) } +function formatActor(event: ActivityEvent): string { + if (event.userId) return event.userId + if (event.actorType === 'anonymous') return 'Anonymous' + if (event.actorType === 'system') return event.actorRef ? `System:${event.actorRef}` : 'System' + if (event.actorType === 'downloader') return event.actorRef ? `Downloader:${event.actorRef}` : 'Downloader' + return 'Unknown' +} + function TeamActivityPage() { const { t } = useTranslation() const { teamId } = Route.useParams()