mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
Merge branch 'main' of github.com:/coder/coder into dk/prebuilds
Signed-off-by: Danny Kopping <dannykopping@gmail.com>
This commit is contained in:
@ -5,13 +5,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
|
@ -34,11 +34,12 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
canAssignRole := rbac.Role{
|
||||
canCreateCustomRole := rbac.Role{
|
||||
Identifier: rbac.RoleIdentifier{Name: "can-assign"},
|
||||
DisplayName: "",
|
||||
Site: rbac.Permissions(map[string][]policy.Action{
|
||||
rbac.ResourceAssignRole.Type: {policy.ActionRead, policy.ActionCreate},
|
||||
rbac.ResourceAssignRole.Type: {policy.ActionRead},
|
||||
rbac.ResourceAssignOrgRole.Type: {policy.ActionRead, policy.ActionCreate},
|
||||
}),
|
||||
}
|
||||
|
||||
@ -61,17 +62,15 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
return all
|
||||
}
|
||||
|
||||
orgID := uuid.NullUUID{
|
||||
UUID: uuid.New(),
|
||||
Valid: true,
|
||||
}
|
||||
orgID := uuid.New()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
subject rbac.ExpandableRoles
|
||||
|
||||
// Perms to create on new custom role
|
||||
organizationID uuid.NullUUID
|
||||
organizationID uuid.UUID
|
||||
site []codersdk.Permission
|
||||
org []codersdk.Permission
|
||||
user []codersdk.Permission
|
||||
@ -79,19 +78,21 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
// No roles, so no assign role
|
||||
name: "no-roles",
|
||||
subject: rbac.RoleIdentifiers{},
|
||||
errorContains: "forbidden",
|
||||
name: "no-roles",
|
||||
organizationID: orgID,
|
||||
subject: rbac.RoleIdentifiers{},
|
||||
errorContains: "forbidden",
|
||||
},
|
||||
{
|
||||
// This works because the new role has 0 perms
|
||||
name: "empty",
|
||||
subject: merge(canAssignRole),
|
||||
name: "empty",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole),
|
||||
},
|
||||
{
|
||||
name: "mixed-scopes",
|
||||
subject: merge(canAssignRole, rbac.RoleOwner()),
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
@ -101,27 +102,30 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
errorContains: "organization roles specify site or user permissions",
|
||||
},
|
||||
{
|
||||
name: "invalid-action",
|
||||
subject: merge(canAssignRole, rbac.RoleOwner()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
name: "invalid-action",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
// Action does not go with resource
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionViewInsights},
|
||||
}),
|
||||
errorContains: "invalid action",
|
||||
},
|
||||
{
|
||||
name: "invalid-resource",
|
||||
subject: merge(canAssignRole, rbac.RoleOwner()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
name: "invalid-resource",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
"foobar": {codersdk.ActionViewInsights},
|
||||
}),
|
||||
errorContains: "invalid resource",
|
||||
},
|
||||
{
|
||||
// Not allowing these at this time.
|
||||
name: "negative-permission",
|
||||
subject: merge(canAssignRole, rbac.RoleOwner()),
|
||||
site: []codersdk.Permission{
|
||||
name: "negative-permission",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
|
||||
org: []codersdk.Permission{
|
||||
{
|
||||
Negate: true,
|
||||
ResourceType: codersdk.ResourceWorkspace,
|
||||
@ -131,89 +135,69 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
errorContains: "no negative permissions",
|
||||
},
|
||||
{
|
||||
name: "wildcard", // not allowed
|
||||
subject: merge(canAssignRole, rbac.RoleOwner()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
name: "wildcard", // not allowed
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleOwner()),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {"*"},
|
||||
}),
|
||||
errorContains: "no wildcard symbols",
|
||||
},
|
||||
// escalation checks
|
||||
{
|
||||
name: "read-workspace-escalation",
|
||||
subject: merge(canAssignRole),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
name: "read-workspace-escalation",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
errorContains: "not allowed to grant this permission",
|
||||
},
|
||||
{
|
||||
name: "read-workspace-outside-org",
|
||||
organizationID: uuid.NullUUID{
|
||||
UUID: uuid.New(),
|
||||
Valid: true,
|
||||
},
|
||||
subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)),
|
||||
name: "read-workspace-outside-org",
|
||||
organizationID: uuid.New(),
|
||||
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
errorContains: "forbidden",
|
||||
errorContains: "not allowed to grant this permission",
|
||||
},
|
||||
{
|
||||
name: "user-escalation",
|
||||
// These roles do not grant user perms
|
||||
subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)),
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
|
||||
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
errorContains: "not allowed to grant this permission",
|
||||
errorContains: "organization roles specify site or user permissions",
|
||||
},
|
||||
{
|
||||
name: "template-admin-escalation",
|
||||
subject: merge(canAssignRole, rbac.RoleTemplateAdmin()),
|
||||
name: "site-escalation",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleTemplateAdmin()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead}, // ok!
|
||||
codersdk.ResourceDeploymentConfig: {codersdk.ActionUpdate}, // not ok!
|
||||
}),
|
||||
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead}, // ok!
|
||||
}),
|
||||
errorContains: "deployment_config",
|
||||
errorContains: "organization roles specify site or user permissions",
|
||||
},
|
||||
// ok!
|
||||
{
|
||||
name: "read-workspace-template-admin",
|
||||
subject: merge(canAssignRole, rbac.RoleTemplateAdmin()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
name: "read-workspace-template-admin",
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.RoleTemplateAdmin()),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "read-workspace-in-org",
|
||||
subject: merge(canAssignRole, rbac.ScopedRoleOrgAdmin(orgID.UUID)),
|
||||
organizationID: orgID,
|
||||
subject: merge(canCreateCustomRole, rbac.ScopedRoleOrgAdmin(orgID)),
|
||||
org: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "user-perms",
|
||||
// This is weird, but is ok
|
||||
subject: merge(canAssignRole, rbac.RoleMember()),
|
||||
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "site+user-perms",
|
||||
subject: merge(canAssignRole, rbac.RoleMember(), rbac.RoleTemplateAdmin()),
|
||||
site: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
user: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@ -234,7 +218,7 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
_, err := az.InsertCustomRole(ctx, database.InsertCustomRoleParams{
|
||||
Name: "test-role",
|
||||
DisplayName: "",
|
||||
OrganizationID: tc.organizationID,
|
||||
OrganizationID: uuid.NullUUID{UUID: tc.organizationID, Valid: true},
|
||||
SitePermissions: db2sdk.List(tc.site, convertSDKPerm),
|
||||
OrgPermissions: db2sdk.List(tc.org, convertSDKPerm),
|
||||
UserPermissions: db2sdk.List(tc.user, convertSDKPerm),
|
||||
@ -249,11 +233,11 @@ func TestInsertCustomRoles(t *testing.T) {
|
||||
LookupRoles: []database.NameOrganizationPair{
|
||||
{
|
||||
Name: "test-role",
|
||||
OrganizationID: tc.organizationID.UUID,
|
||||
OrganizationID: tc.organizationID,
|
||||
},
|
||||
},
|
||||
ExcludeOrgRoles: false,
|
||||
OrganizationID: uuid.UUID{},
|
||||
OrganizationID: uuid.Nil,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, roles, 1)
|
||||
|
@ -5,13 +5,13 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/open-policy-agent/opa/topdown"
|
||||
@ -281,6 +281,7 @@ var (
|
||||
DisplayName: "Notifier",
|
||||
Site: rbac.Permissions(map[string][]policy.Action{
|
||||
rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
rbac.ResourceInboxNotification.Type: {policy.ActionCreate},
|
||||
}),
|
||||
Org: map[string][]rbac.Permission{},
|
||||
User: []rbac.Permission{},
|
||||
@ -747,7 +748,7 @@ func (*querier) convertToDeploymentRoles(names []string) []rbac.RoleIdentifier {
|
||||
}
|
||||
|
||||
// canAssignRoles handles assigning built in and custom roles.
|
||||
func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, removed []rbac.RoleIdentifier) error {
|
||||
func (q *querier) canAssignRoles(ctx context.Context, orgID uuid.UUID, added, removed []rbac.RoleIdentifier) error {
|
||||
actor, ok := ActorFromContext(ctx)
|
||||
if !ok {
|
||||
return NoActorError
|
||||
@ -755,12 +756,14 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
|
||||
|
||||
roleAssign := rbac.ResourceAssignRole
|
||||
shouldBeOrgRoles := false
|
||||
if orgID != nil {
|
||||
roleAssign = rbac.ResourceAssignOrgRole.InOrg(*orgID)
|
||||
if orgID != uuid.Nil {
|
||||
roleAssign = rbac.ResourceAssignOrgRole.InOrg(orgID)
|
||||
shouldBeOrgRoles = true
|
||||
}
|
||||
|
||||
grantedRoles := append(added, removed...)
|
||||
grantedRoles := make([]rbac.RoleIdentifier, 0, len(added)+len(removed))
|
||||
grantedRoles = append(grantedRoles, added...)
|
||||
grantedRoles = append(grantedRoles, removed...)
|
||||
customRoles := make([]rbac.RoleIdentifier, 0)
|
||||
// Validate that the roles being assigned are valid.
|
||||
for _, r := range grantedRoles {
|
||||
@ -774,11 +777,11 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
|
||||
}
|
||||
|
||||
if shouldBeOrgRoles {
|
||||
if orgID == nil {
|
||||
if orgID == uuid.Nil {
|
||||
return xerrors.Errorf("should never happen, orgID is nil, but trying to assign an organization role")
|
||||
}
|
||||
|
||||
if r.OrganizationID != *orgID {
|
||||
if r.OrganizationID != orgID {
|
||||
return xerrors.Errorf("attempted to assign role from a different org, role %q to %q", r, orgID.String())
|
||||
}
|
||||
}
|
||||
@ -824,7 +827,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
|
||||
}
|
||||
|
||||
if len(removed) > 0 {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, roleAssign); err != nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUnassign, roleAssign); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -1133,11 +1136,23 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error {
|
||||
return q.db.CleanTailnetTunnels(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceInboxNotification.WithOwner(userID.String())); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return q.db.CountUnreadInboxNotificationsByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// TODO: Handle org scoped lookups
|
||||
func (q *querier) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAssignRole); err != nil {
|
||||
roleObject := rbac.ResourceAssignRole
|
||||
if arg.OrganizationID != uuid.Nil {
|
||||
roleObject = rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID)
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, roleObject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return q.db.CustomRoles(ctx, arg)
|
||||
}
|
||||
|
||||
@ -1194,14 +1209,11 @@ func (q *querier) DeleteCryptoKey(ctx context.Context, arg database.DeleteCrypto
|
||||
}
|
||||
|
||||
func (q *querier) DeleteCustomRole(ctx context.Context, arg database.DeleteCustomRoleParams) error {
|
||||
if arg.OrganizationID.UUID != uuid.Nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignRole); err != nil {
|
||||
return err
|
||||
}
|
||||
if !arg.OrganizationID.Valid || arg.OrganizationID.UUID == uuid.Nil {
|
||||
return NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return q.db.DeleteCustomRole(ctx, arg)
|
||||
@ -1435,6 +1447,17 @@ func (q *querier) FetchMemoryResourceMonitorsByAgentID(ctx context.Context, agen
|
||||
return q.db.FetchMemoryResourceMonitorsByAgentID(ctx, agentID)
|
||||
}
|
||||
|
||||
func (q *querier) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
|
||||
// Ideally, we would return a list of monitors that the user has access to. However, that check would need to
|
||||
// be implemented similarly to GetWorkspaces, which is more complex than what we're doing here. Since this query
|
||||
// was introduced for telemetry, we perform a simpler check.
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspaceAgentResourceMonitor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return q.db.FetchMemoryResourceMonitorsUpdatedAfter(ctx, updatedAt)
|
||||
}
|
||||
|
||||
func (q *querier) FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationMessage); err != nil {
|
||||
return database.FetchNewMessageMetadataRow{}, err
|
||||
@ -1456,6 +1479,17 @@ func (q *querier) FetchVolumesResourceMonitorsByAgentID(ctx context.Context, age
|
||||
return q.db.FetchVolumesResourceMonitorsByAgentID(ctx, agentID)
|
||||
}
|
||||
|
||||
func (q *querier) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
// Ideally, we would return a list of monitors that the user has access to. However, that check would need to
|
||||
// be implemented similarly to GetWorkspaces, which is more complex than what we're doing here. Since this query
|
||||
// was introduced for telemetry, we perform a simpler check.
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspaceAgentResourceMonitor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return q.db.FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt)
|
||||
}
|
||||
|
||||
func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
|
||||
return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id)
|
||||
}
|
||||
@ -1695,6 +1729,10 @@ func (q *querier) GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]dat
|
||||
return q.db.GetFileTemplates(ctx, fileID)
|
||||
}
|
||||
|
||||
func (q *querier) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
|
||||
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetFilteredInboxNotificationsByUserID)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) {
|
||||
return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetGitSSHKey)(ctx, userID)
|
||||
}
|
||||
@ -1754,6 +1792,14 @@ func (q *querier) GetHungProvisionerJobs(ctx context.Context, hungSince time.Tim
|
||||
return q.db.GetHungProvisionerJobs(ctx, hungSince)
|
||||
}
|
||||
|
||||
func (q *querier) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) {
|
||||
return fetchWithAction(q.log, q.auth, policy.ActionRead, q.db.GetInboxNotificationByID)(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) GetInboxNotificationsByUserID(ctx context.Context, userID database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
|
||||
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetInboxNotificationsByUserID)(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) {
|
||||
if _, err := fetch(q.log, q.auth, q.db.GetWorkspaceByID)(ctx, arg.WorkspaceID); err != nil {
|
||||
return database.JfrogXrayScan{}, err
|
||||
@ -3046,14 +3092,11 @@ func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCrypto
|
||||
|
||||
func (q *querier) InsertCustomRole(ctx context.Context, arg database.InsertCustomRoleParams) (database.CustomRole, error) {
|
||||
// Org and site role upsert share the same query. So switch the assertion based on the org uuid.
|
||||
if arg.OrganizationID.UUID != uuid.Nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
} else {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignRole); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
if !arg.OrganizationID.Valid || arg.OrganizationID.UUID == uuid.Nil {
|
||||
return database.CustomRole{}, NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
|
||||
if err := q.customRoleCheck(ctx, database.CustomRole{
|
||||
@ -3116,6 +3159,10 @@ func (q *querier) InsertGroupMember(ctx context.Context, arg database.InsertGrou
|
||||
return update(q.log, q.auth, fetch, q.db.InsertGroupMember)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertInboxNotification(ctx context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) {
|
||||
return insert(q.log, q.auth, rbac.ResourceInboxNotification.WithOwner(arg.UserID.String()), q.db.InsertInboxNotification)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertLicense(ctx context.Context, arg database.InsertLicenseParams) (database.License, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceLicense); err != nil {
|
||||
return database.License{}, err
|
||||
@ -3183,7 +3230,7 @@ func (q *querier) InsertOrganizationMember(ctx context.Context, arg database.Ins
|
||||
|
||||
// All roles are added roles. Org member is always implied.
|
||||
addedRoles := append(orgRoles, rbac.ScopedRoleOrgMember(arg.OrganizationID))
|
||||
err = q.canAssignRoles(ctx, &arg.OrganizationID, addedRoles, []rbac.RoleIdentifier{})
|
||||
err = q.canAssignRoles(ctx, arg.OrganizationID, addedRoles, []rbac.RoleIdentifier{})
|
||||
if err != nil {
|
||||
return database.OrganizationMember{}, err
|
||||
}
|
||||
@ -3314,7 +3361,7 @@ func (q *querier) InsertTemplateVersionWorkspaceTag(ctx context.Context, arg dat
|
||||
func (q *querier) InsertUser(ctx context.Context, arg database.InsertUserParams) (database.User, error) {
|
||||
// Always check if the assigned roles can actually be assigned by this actor.
|
||||
impliedRoles := append([]rbac.RoleIdentifier{rbac.RoleMember()}, q.convertToDeploymentRoles(arg.RBACRoles)...)
|
||||
err := q.canAssignRoles(ctx, nil, impliedRoles, []rbac.RoleIdentifier{})
|
||||
err := q.canAssignRoles(ctx, uuid.Nil, impliedRoles, []rbac.RoleIdentifier{})
|
||||
if err != nil {
|
||||
return database.User{}, err
|
||||
}
|
||||
@ -3652,14 +3699,11 @@ func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.Upd
|
||||
}
|
||||
|
||||
func (q *querier) UpdateCustomRole(ctx context.Context, arg database.UpdateCustomRoleParams) (database.CustomRole, error) {
|
||||
if arg.OrganizationID.UUID != uuid.Nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
} else {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAssignRole); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
if !arg.OrganizationID.Valid || arg.OrganizationID.UUID == uuid.Nil {
|
||||
return database.CustomRole{}, NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
|
||||
return database.CustomRole{}, err
|
||||
}
|
||||
|
||||
if err := q.customRoleCheck(ctx, database.CustomRole{
|
||||
@ -3713,6 +3757,14 @@ func (q *querier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfte
|
||||
return q.db.UpdateInactiveUsersToDormant(ctx, lastSeenAfter)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateInboxNotificationReadStatus(ctx context.Context, args database.UpdateInboxNotificationReadStatusParams) error {
|
||||
fetchFunc := func(ctx context.Context, args database.UpdateInboxNotificationReadStatusParams) (database.InboxNotification, error) {
|
||||
return q.db.GetInboxNotificationByID(ctx, args.ID)
|
||||
}
|
||||
|
||||
return update(q.log, q.auth, fetchFunc, q.db.UpdateInboxNotificationReadStatus)(ctx, args)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
|
||||
// Authorized fetch will check that the actor has read access to the org member since the org member is returned.
|
||||
member, err := database.ExpectOne(q.OrganizationMembers(ctx, database.OrganizationMembersParams{
|
||||
@ -3739,7 +3791,7 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb
|
||||
impliedTypes := append(scopedGranted, rbac.ScopedRoleOrgMember(arg.OrgID))
|
||||
|
||||
added, removed := rbac.ChangeRoleSet(originalRoles, impliedTypes)
|
||||
err = q.canAssignRoles(ctx, &arg.OrgID, added, removed)
|
||||
err = q.canAssignRoles(ctx, arg.OrgID, added, removed)
|
||||
if err != nil {
|
||||
return database.OrganizationMember{}, err
|
||||
}
|
||||
@ -4146,7 +4198,7 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo
|
||||
impliedTypes := append(q.convertToDeploymentRoles(arg.GrantedRoles), rbac.RoleMember())
|
||||
// If the changeset is nothing, less rbac checks need to be done.
|
||||
added, removed := rbac.ChangeRoleSet(q.convertToDeploymentRoles(user.RBACRoles), impliedTypes)
|
||||
err = q.canAssignRoles(ctx, nil, added, removed)
|
||||
err = q.canAssignRoles(ctx, uuid.Nil, added, removed)
|
||||
if err != nil {
|
||||
return database.User{}, err
|
||||
}
|
||||
|
@ -1011,7 +1011,7 @@ func (s *MethodTestSuite) TestOrganization() {
|
||||
Asserts(
|
||||
mem, policy.ActionRead,
|
||||
rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign, // org-mem
|
||||
rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionDelete, // org-admin
|
||||
rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionUnassign, // org-admin
|
||||
).Returns(out)
|
||||
}))
|
||||
}
|
||||
@ -1619,7 +1619,7 @@ func (s *MethodTestSuite) TestUser() {
|
||||
}).Asserts(
|
||||
u, policy.ActionRead,
|
||||
rbac.ResourceAssignRole, policy.ActionAssign,
|
||||
rbac.ResourceAssignRole, policy.ActionDelete,
|
||||
rbac.ResourceAssignRole, policy.ActionUnassign,
|
||||
).Returns(o)
|
||||
}))
|
||||
s.Run("AllUserIDs", s.Subtest(func(db database.Store, check *expects) {
|
||||
@ -1653,30 +1653,28 @@ func (s *MethodTestSuite) TestUser() {
|
||||
check.Args(database.DeleteCustomRoleParams{
|
||||
Name: customRole.Name,
|
||||
}).Asserts(
|
||||
rbac.ResourceAssignRole, policy.ActionDelete)
|
||||
// fails immediately, missing organization id
|
||||
).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")})
|
||||
}))
|
||||
s.Run("Blank/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
|
||||
customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{})
|
||||
customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{
|
||||
OrganizationID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
})
|
||||
// Blank is no perms in the role
|
||||
check.Args(database.UpdateCustomRoleParams{
|
||||
Name: customRole.Name,
|
||||
DisplayName: "Test Name",
|
||||
OrganizationID: customRole.OrganizationID,
|
||||
SitePermissions: nil,
|
||||
OrgPermissions: nil,
|
||||
UserPermissions: nil,
|
||||
}).Asserts(rbac.ResourceAssignRole, policy.ActionUpdate).ErrorsWithPG(sql.ErrNoRows)
|
||||
}).Asserts(rbac.ResourceAssignOrgRole.InOrg(customRole.OrganizationID.UUID), policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("SitePermissions/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{
|
||||
OrganizationID: uuid.NullUUID{
|
||||
UUID: uuid.Nil,
|
||||
Valid: false,
|
||||
},
|
||||
})
|
||||
check.Args(database.UpdateCustomRoleParams{
|
||||
Name: customRole.Name,
|
||||
OrganizationID: customRole.OrganizationID,
|
||||
Name: "",
|
||||
OrganizationID: uuid.NullUUID{UUID: uuid.Nil, Valid: false},
|
||||
DisplayName: "Test Name",
|
||||
SitePermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
|
||||
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionDelete, codersdk.ActionViewInsights},
|
||||
@ -1686,17 +1684,8 @@ func (s *MethodTestSuite) TestUser() {
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}), convertSDKPerm),
|
||||
}).Asserts(
|
||||
// First check
|
||||
rbac.ResourceAssignRole, policy.ActionUpdate,
|
||||
// Escalation checks
|
||||
rbac.ResourceTemplate, policy.ActionCreate,
|
||||
rbac.ResourceTemplate, policy.ActionRead,
|
||||
rbac.ResourceTemplate, policy.ActionUpdate,
|
||||
rbac.ResourceTemplate, policy.ActionDelete,
|
||||
rbac.ResourceTemplate, policy.ActionViewInsights,
|
||||
|
||||
rbac.ResourceWorkspace.WithOwner(testActorID.String()), policy.ActionRead,
|
||||
).ErrorsWithPG(sql.ErrNoRows)
|
||||
// fails immediately, missing organization id
|
||||
).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")})
|
||||
}))
|
||||
s.Run("OrgPermissions/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
orgID := uuid.New()
|
||||
@ -1726,13 +1715,15 @@ func (s *MethodTestSuite) TestUser() {
|
||||
}))
|
||||
s.Run("Blank/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
// Blank is no perms in the role
|
||||
orgID := uuid.New()
|
||||
check.Args(database.InsertCustomRoleParams{
|
||||
Name: "test",
|
||||
DisplayName: "Test Name",
|
||||
OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true},
|
||||
SitePermissions: nil,
|
||||
OrgPermissions: nil,
|
||||
UserPermissions: nil,
|
||||
}).Asserts(rbac.ResourceAssignRole, policy.ActionCreate)
|
||||
}).Asserts(rbac.ResourceAssignOrgRole.InOrg(orgID), policy.ActionCreate)
|
||||
}))
|
||||
s.Run("SitePermissions/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args(database.InsertCustomRoleParams{
|
||||
@ -1746,17 +1737,8 @@ func (s *MethodTestSuite) TestUser() {
|
||||
codersdk.ResourceWorkspace: {codersdk.ActionRead},
|
||||
}), convertSDKPerm),
|
||||
}).Asserts(
|
||||
// First check
|
||||
rbac.ResourceAssignRole, policy.ActionCreate,
|
||||
// Escalation checks
|
||||
rbac.ResourceTemplate, policy.ActionCreate,
|
||||
rbac.ResourceTemplate, policy.ActionRead,
|
||||
rbac.ResourceTemplate, policy.ActionUpdate,
|
||||
rbac.ResourceTemplate, policy.ActionDelete,
|
||||
rbac.ResourceTemplate, policy.ActionViewInsights,
|
||||
|
||||
rbac.ResourceWorkspace.WithOwner(testActorID.String()), policy.ActionRead,
|
||||
)
|
||||
// fails immediately, missing organization id
|
||||
).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")})
|
||||
}))
|
||||
s.Run("OrgPermissions/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) {
|
||||
orgID := uuid.New()
|
||||
@ -4484,6 +4466,141 @@ func (s *MethodTestSuite) TestNotifications() {
|
||||
Disableds: []bool{true, false},
|
||||
}).Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionUpdate)
|
||||
}))
|
||||
|
||||
s.Run("GetInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) {
|
||||
u := dbgen.User(s.T(), db, database.User{})
|
||||
|
||||
notifID := uuid.New()
|
||||
|
||||
notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{
|
||||
ID: notifID,
|
||||
UserID: u.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
|
||||
Title: "test title",
|
||||
Content: "test content notification",
|
||||
Icon: "https://coder.com/favicon.ico",
|
||||
Actions: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
check.Args(database.GetInboxNotificationsByUserIDParams{
|
||||
UserID: u.ID,
|
||||
ReadStatus: database.InboxNotificationReadStatusAll,
|
||||
}).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns([]database.InboxNotification{notif})
|
||||
}))
|
||||
|
||||
s.Run("GetFilteredInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) {
|
||||
u := dbgen.User(s.T(), db, database.User{})
|
||||
|
||||
notifID := uuid.New()
|
||||
|
||||
targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}
|
||||
|
||||
notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{
|
||||
ID: notifID,
|
||||
UserID: u.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
|
||||
Targets: targets,
|
||||
Title: "test title",
|
||||
Content: "test content notification",
|
||||
Icon: "https://coder.com/favicon.ico",
|
||||
Actions: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
check.Args(database.GetFilteredInboxNotificationsByUserIDParams{
|
||||
UserID: u.ID,
|
||||
Templates: []uuid.UUID{notifications.TemplateWorkspaceAutoUpdated},
|
||||
Targets: []uuid.UUID{u.ID},
|
||||
ReadStatus: database.InboxNotificationReadStatusAll,
|
||||
}).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns([]database.InboxNotification{notif})
|
||||
}))
|
||||
|
||||
s.Run("GetInboxNotificationByID", s.Subtest(func(db database.Store, check *expects) {
|
||||
u := dbgen.User(s.T(), db, database.User{})
|
||||
|
||||
notifID := uuid.New()
|
||||
|
||||
targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}
|
||||
|
||||
notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{
|
||||
ID: notifID,
|
||||
UserID: u.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
|
||||
Targets: targets,
|
||||
Title: "test title",
|
||||
Content: "test content notification",
|
||||
Icon: "https://coder.com/favicon.ico",
|
||||
Actions: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
check.Args(notifID).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns(notif)
|
||||
}))
|
||||
|
||||
s.Run("CountUnreadInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) {
|
||||
u := dbgen.User(s.T(), db, database.User{})
|
||||
|
||||
notifID := uuid.New()
|
||||
|
||||
targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}
|
||||
|
||||
_ = dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{
|
||||
ID: notifID,
|
||||
UserID: u.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
|
||||
Targets: targets,
|
||||
Title: "test title",
|
||||
Content: "test content notification",
|
||||
Icon: "https://coder.com/favicon.ico",
|
||||
Actions: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
check.Args(u.ID).Asserts(rbac.ResourceInboxNotification.WithOwner(u.ID.String()), policy.ActionRead).Returns(int64(1))
|
||||
}))
|
||||
|
||||
s.Run("InsertInboxNotification", s.Subtest(func(db database.Store, check *expects) {
|
||||
u := dbgen.User(s.T(), db, database.User{})
|
||||
|
||||
notifID := uuid.New()
|
||||
|
||||
targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}
|
||||
|
||||
check.Args(database.InsertInboxNotificationParams{
|
||||
ID: notifID,
|
||||
UserID: u.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
|
||||
Targets: targets,
|
||||
Title: "test title",
|
||||
Content: "test content notification",
|
||||
Icon: "https://coder.com/favicon.ico",
|
||||
Actions: json.RawMessage("{}"),
|
||||
}).Asserts(rbac.ResourceInboxNotification.WithOwner(u.ID.String()), policy.ActionCreate)
|
||||
}))
|
||||
|
||||
s.Run("UpdateInboxNotificationReadStatus", s.Subtest(func(db database.Store, check *expects) {
|
||||
u := dbgen.User(s.T(), db, database.User{})
|
||||
|
||||
notifID := uuid.New()
|
||||
|
||||
targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}
|
||||
readAt := dbtestutil.NowInDefaultTimezone()
|
||||
|
||||
notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{
|
||||
ID: notifID,
|
||||
UserID: u.ID,
|
||||
TemplateID: notifications.TemplateWorkspaceAutoUpdated,
|
||||
Targets: targets,
|
||||
Title: "test title",
|
||||
Content: "test content notification",
|
||||
Icon: "https://coder.com/favicon.ico",
|
||||
Actions: json.RawMessage("{}"),
|
||||
})
|
||||
|
||||
notif.ReadAt = sql.NullTime{Time: readAt, Valid: true}
|
||||
|
||||
check.Args(database.UpdateInboxNotificationReadStatusParams{
|
||||
ID: notifID,
|
||||
ReadAt: sql.NullTime{Time: readAt, Valid: true},
|
||||
}).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionUpdate)
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *MethodTestSuite) TestOAuth2ProviderApps() {
|
||||
@ -4802,6 +4919,14 @@ func (s *MethodTestSuite) TestResourcesMonitor() {
|
||||
}).Asserts(rbac.ResourceWorkspaceAgentResourceMonitor, policy.ActionUpdate)
|
||||
}))
|
||||
|
||||
s.Run("FetchMemoryResourceMonitorsUpdatedAfter", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args(dbtime.Now()).Asserts(rbac.ResourceWorkspaceAgentResourceMonitor, policy.ActionRead)
|
||||
}))
|
||||
|
||||
s.Run("FetchVolumesResourceMonitorsUpdatedAfter", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args(dbtime.Now()).Asserts(rbac.ResourceWorkspaceAgentResourceMonitor, policy.ActionRead)
|
||||
}))
|
||||
|
||||
s.Run("FetchMemoryResourceMonitorsByAgentID", s.Subtest(func(db database.Store, check *expects) {
|
||||
agt, w := createAgent(s.T(), db)
|
||||
|
||||
|
@ -450,6 +450,22 @@ func OrganizationMember(t testing.TB, db database.Store, orig database.Organizat
|
||||
return mem
|
||||
}
|
||||
|
||||
func NotificationInbox(t testing.TB, db database.Store, orig database.InsertInboxNotificationParams) database.InboxNotification {
|
||||
notification, err := db.InsertInboxNotification(genCtx, database.InsertInboxNotificationParams{
|
||||
ID: takeFirst(orig.ID, uuid.New()),
|
||||
UserID: takeFirst(orig.UserID, uuid.New()),
|
||||
TemplateID: takeFirst(orig.TemplateID, uuid.New()),
|
||||
Targets: takeFirstSlice(orig.Targets, []uuid.UUID{}),
|
||||
Title: takeFirst(orig.Title, testutil.GetRandomName(t)),
|
||||
Content: takeFirst(orig.Content, testutil.GetRandomName(t)),
|
||||
Icon: takeFirst(orig.Icon, ""),
|
||||
Actions: orig.Actions,
|
||||
CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
|
||||
})
|
||||
require.NoError(t, err, "insert notification")
|
||||
return notification
|
||||
}
|
||||
|
||||
func Group(t testing.TB, db database.Store, orig database.Group) database.Group {
|
||||
t.Helper()
|
||||
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -19,7 +20,6 @@ import (
|
||||
"github.com/lib/pq"
|
||||
"golang.org/x/exp/constraints"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/notifications/types"
|
||||
@ -67,6 +67,7 @@ func New() database.Store {
|
||||
gitSSHKey: make([]database.GitSSHKey, 0),
|
||||
notificationMessages: make([]database.NotificationMessage, 0),
|
||||
notificationPreferences: make([]database.NotificationPreference, 0),
|
||||
InboxNotification: make([]database.InboxNotification, 0),
|
||||
parameterSchemas: make([]database.ParameterSchema, 0),
|
||||
provisionerDaemons: make([]database.ProvisionerDaemon, 0),
|
||||
provisionerKeys: make([]database.ProvisionerKey, 0),
|
||||
@ -206,6 +207,7 @@ type data struct {
|
||||
notificationMessages []database.NotificationMessage
|
||||
notificationPreferences []database.NotificationPreference
|
||||
notificationReportGeneratorLogs []database.NotificationReportGeneratorLog
|
||||
InboxNotification []database.InboxNotification
|
||||
oauth2ProviderApps []database.OAuth2ProviderApp
|
||||
oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret
|
||||
oauth2ProviderAppCodes []database.OAuth2ProviderAppCode
|
||||
@ -269,7 +271,7 @@ type data struct {
|
||||
presetParameters []database.TemplateVersionPresetParameter
|
||||
}
|
||||
|
||||
func tryPercentile(fs []float64, p float64) float64 {
|
||||
func tryPercentileCont(fs []float64, p float64) float64 {
|
||||
if len(fs) == 0 {
|
||||
return -1
|
||||
}
|
||||
@ -282,6 +284,14 @@ func tryPercentile(fs []float64, p float64) float64 {
|
||||
return fs[lower] + (fs[upper]-fs[lower])*(pos-float64(lower))
|
||||
}
|
||||
|
||||
func tryPercentileDisc(fs []float64, p float64) float64 {
|
||||
if len(fs) == 0 {
|
||||
return -1
|
||||
}
|
||||
sort.Float64s(fs)
|
||||
return fs[max(int(math.Ceil(float64(len(fs))*p/100-1)), 0)]
|
||||
}
|
||||
|
||||
func validateDatabaseTypeWithValid(v reflect.Value) (handled bool, err error) {
|
||||
if v.Kind() == reflect.Struct {
|
||||
return false, nil
|
||||
@ -1139,7 +1149,119 @@ func getOwnerFromTags(tags map[string]string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) getProvisionerJobsByIDsWithQueuePositionLocked(_ context.Context, ids []uuid.UUID) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) {
|
||||
// provisionerTagsetContains checks if daemonTags contain all key-value pairs from jobTags
|
||||
func provisionerTagsetContains(daemonTags, jobTags map[string]string) bool {
|
||||
for jobKey, jobValue := range jobTags {
|
||||
if daemonValue, exists := daemonTags[jobKey]; !exists || daemonValue != jobValue {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetProvisionerJobsByIDsWithQueuePosition mimics the SQL logic in pure Go
|
||||
func (q *FakeQuerier) getProvisionerJobsByIDsWithQueuePositionLockedTagBasedQueue(_ context.Context, jobIDs []uuid.UUID) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) {
|
||||
// Step 1: Filter provisionerJobs based on jobIDs
|
||||
filteredJobs := make(map[uuid.UUID]database.ProvisionerJob)
|
||||
for _, job := range q.provisionerJobs {
|
||||
for _, id := range jobIDs {
|
||||
if job.ID == id {
|
||||
filteredJobs[job.ID] = job
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Identify pending jobs
|
||||
pendingJobs := make(map[uuid.UUID]database.ProvisionerJob)
|
||||
for _, job := range q.provisionerJobs {
|
||||
if job.JobStatus == "pending" {
|
||||
pendingJobs[job.ID] = job
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Identify pending jobs that have a matching provisioner
|
||||
matchedJobs := make(map[uuid.UUID]struct{})
|
||||
for _, job := range pendingJobs {
|
||||
for _, daemon := range q.provisionerDaemons {
|
||||
if provisionerTagsetContains(daemon.Tags, job.Tags) {
|
||||
matchedJobs[job.ID] = struct{}{}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Rank pending jobs per provisioner
|
||||
jobRanks := make(map[uuid.UUID][]database.ProvisionerJob)
|
||||
for _, job := range pendingJobs {
|
||||
for _, daemon := range q.provisionerDaemons {
|
||||
if provisionerTagsetContains(daemon.Tags, job.Tags) {
|
||||
jobRanks[daemon.ID] = append(jobRanks[daemon.ID], job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort jobs per provisioner by CreatedAt
|
||||
for daemonID := range jobRanks {
|
||||
sort.Slice(jobRanks[daemonID], func(i, j int) bool {
|
||||
return jobRanks[daemonID][i].CreatedAt.Before(jobRanks[daemonID][j].CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// Step 5: Compute queue position & max queue size across all provisioners
|
||||
jobQueueStats := make(map[uuid.UUID]database.GetProvisionerJobsByIDsWithQueuePositionRow)
|
||||
for _, jobs := range jobRanks {
|
||||
queueSize := int64(len(jobs)) // Queue size per provisioner
|
||||
for i, job := range jobs {
|
||||
queuePosition := int64(i + 1)
|
||||
|
||||
// If the job already exists, update only if this queuePosition is better
|
||||
if existing, exists := jobQueueStats[job.ID]; exists {
|
||||
jobQueueStats[job.ID] = database.GetProvisionerJobsByIDsWithQueuePositionRow{
|
||||
ID: job.ID,
|
||||
CreatedAt: job.CreatedAt,
|
||||
ProvisionerJob: job,
|
||||
QueuePosition: min(existing.QueuePosition, queuePosition),
|
||||
QueueSize: max(existing.QueueSize, queueSize), // Take the maximum queue size across provisioners
|
||||
}
|
||||
} else {
|
||||
jobQueueStats[job.ID] = database.GetProvisionerJobsByIDsWithQueuePositionRow{
|
||||
ID: job.ID,
|
||||
CreatedAt: job.CreatedAt,
|
||||
ProvisionerJob: job,
|
||||
QueuePosition: queuePosition,
|
||||
QueueSize: queueSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Compute the final results with minimal checks
|
||||
var results []database.GetProvisionerJobsByIDsWithQueuePositionRow
|
||||
for _, job := range filteredJobs {
|
||||
// If the job has a computed rank, use it
|
||||
if rank, found := jobQueueStats[job.ID]; found {
|
||||
results = append(results, rank)
|
||||
} else {
|
||||
// Otherwise, return (0,0) for non-pending jobs and unranked pending jobs
|
||||
results = append(results, database.GetProvisionerJobsByIDsWithQueuePositionRow{
|
||||
ID: job.ID,
|
||||
CreatedAt: job.CreatedAt,
|
||||
ProvisionerJob: job,
|
||||
QueuePosition: 0,
|
||||
QueueSize: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Sort results by CreatedAt
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].CreatedAt.Before(results[j].CreatedAt)
|
||||
})
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) getProvisionerJobsByIDsWithQueuePositionLockedGlobalQueue(_ context.Context, ids []uuid.UUID) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) {
|
||||
// WITH pending_jobs AS (
|
||||
// SELECT
|
||||
// id, created_at
|
||||
@ -1602,6 +1724,26 @@ func (*FakeQuerier) CleanTailnetTunnels(context.Context) error {
|
||||
return ErrUnimplemented
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) CountUnreadInboxNotificationsByUserID(_ context.Context, userID uuid.UUID) (int64, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
var count int64
|
||||
for _, notification := range q.InboxNotification {
|
||||
if notification.UserID != userID {
|
||||
continue
|
||||
}
|
||||
|
||||
if notification.ReadAt.Valid {
|
||||
continue
|
||||
}
|
||||
|
||||
count++
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
@ -2365,6 +2507,19 @@ func (q *FakeQuerier) FetchMemoryResourceMonitorsByAgentID(_ context.Context, ag
|
||||
return database.WorkspaceAgentMemoryResourceMonitor{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) FetchMemoryResourceMonitorsUpdatedAfter(_ context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
monitors := []database.WorkspaceAgentMemoryResourceMonitor{}
|
||||
for _, monitor := range q.workspaceAgentMemoryResourceMonitors {
|
||||
if monitor.UpdatedAt.After(updatedAt) {
|
||||
monitors = append(monitors, monitor)
|
||||
}
|
||||
}
|
||||
return monitors, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) FetchNewMessageMetadata(_ context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
@ -2409,6 +2564,19 @@ func (q *FakeQuerier) FetchVolumesResourceMonitorsByAgentID(_ context.Context, a
|
||||
return monitors, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) FetchVolumesResourceMonitorsUpdatedAfter(_ context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
monitors := []database.WorkspaceAgentVolumeResourceMonitor{}
|
||||
for _, monitor := range q.workspaceAgentVolumeResourceMonitors {
|
||||
if monitor.UpdatedAt.After(updatedAt) {
|
||||
monitors = append(monitors, monitor)
|
||||
}
|
||||
}
|
||||
return monitors, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIKey, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
@ -2794,8 +2962,8 @@ func (q *FakeQuerier) GetDeploymentWorkspaceAgentStats(_ context.Context, create
|
||||
latencies = append(latencies, agentStat.ConnectionMedianLatencyMS)
|
||||
}
|
||||
|
||||
stat.WorkspaceConnectionLatency50 = tryPercentile(latencies, 50)
|
||||
stat.WorkspaceConnectionLatency95 = tryPercentile(latencies, 95)
|
||||
stat.WorkspaceConnectionLatency50 = tryPercentileCont(latencies, 50)
|
||||
stat.WorkspaceConnectionLatency95 = tryPercentileCont(latencies, 95)
|
||||
|
||||
return stat, nil
|
||||
}
|
||||
@ -2843,8 +3011,8 @@ func (q *FakeQuerier) GetDeploymentWorkspaceAgentUsageStats(_ context.Context, c
|
||||
stat.WorkspaceTxBytes += agentStat.TxBytes
|
||||
latencies = append(latencies, agentStat.ConnectionMedianLatencyMS)
|
||||
}
|
||||
stat.WorkspaceConnectionLatency50 = tryPercentile(latencies, 50)
|
||||
stat.WorkspaceConnectionLatency95 = tryPercentile(latencies, 95)
|
||||
stat.WorkspaceConnectionLatency50 = tryPercentileCont(latencies, 50)
|
||||
stat.WorkspaceConnectionLatency95 = tryPercentileCont(latencies, 95)
|
||||
|
||||
for _, agentStat := range sessions {
|
||||
stat.SessionCountVSCode += agentStat.SessionCountVSCode
|
||||
@ -3126,6 +3294,45 @@ func (q *FakeQuerier) GetFileTemplates(_ context.Context, id uuid.UUID) ([]datab
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetFilteredInboxNotificationsByUserID(_ context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
notifications := make([]database.InboxNotification, 0)
|
||||
for _, notification := range q.InboxNotification {
|
||||
if notification.UserID == arg.UserID {
|
||||
for _, template := range arg.Templates {
|
||||
templateFound := false
|
||||
if notification.TemplateID == template {
|
||||
templateFound = true
|
||||
}
|
||||
|
||||
if !templateFound {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for _, target := range arg.Targets {
|
||||
isFound := false
|
||||
for _, insertedTarget := range notification.Targets {
|
||||
if insertedTarget == target {
|
||||
isFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isFound {
|
||||
continue
|
||||
}
|
||||
|
||||
notifications = append(notifications, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetGitSSHKey(_ context.Context, userID uuid.UUID) (database.GitSSHKey, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
@ -3324,6 +3531,33 @@ func (q *FakeQuerier) GetHungProvisionerJobs(_ context.Context, hungSince time.T
|
||||
return hungJobs, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetInboxNotificationByID(_ context.Context, id uuid.UUID) (database.InboxNotification, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
for _, notification := range q.InboxNotification {
|
||||
if notification.ID == id {
|
||||
return notification, nil
|
||||
}
|
||||
}
|
||||
|
||||
return database.InboxNotification{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetInboxNotificationsByUserID(_ context.Context, params database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
notifications := make([]database.InboxNotification, 0)
|
||||
for _, notification := range q.InboxNotification {
|
||||
if notification.UserID == params.UserID {
|
||||
notifications = append(notifications, notification)
|
||||
}
|
||||
}
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetJFrogXrayScanByWorkspaceAndAgentID(_ context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
@ -4085,7 +4319,7 @@ func (q *FakeQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.
|
||||
}
|
||||
|
||||
slices.SortFunc(rows, func(a, b database.GetProvisionerDaemonsWithStatusByOrganizationRow) int {
|
||||
return a.ProvisionerDaemon.CreatedAt.Compare(b.ProvisionerDaemon.CreatedAt)
|
||||
return b.ProvisionerDaemon.CreatedAt.Compare(a.ProvisionerDaemon.CreatedAt)
|
||||
})
|
||||
|
||||
if arg.Limit.Valid && arg.Limit.Int32 > 0 && len(rows) > int(arg.Limit.Int32) {
|
||||
@ -4153,7 +4387,7 @@ func (q *FakeQuerier) GetProvisionerJobsByIDsWithQueuePosition(ctx context.Conte
|
||||
if ids == nil {
|
||||
ids = []uuid.UUID{}
|
||||
}
|
||||
return q.getProvisionerJobsByIDsWithQueuePositionLocked(ctx, ids)
|
||||
return q.getProvisionerJobsByIDsWithQueuePositionLockedTagBasedQueue(ctx, ids)
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx context.Context, arg database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) ([]database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow, error) {
|
||||
@ -4222,7 +4456,7 @@ func (q *FakeQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePosition
|
||||
LIMIT
|
||||
sqlc.narg('limit')::int;
|
||||
*/
|
||||
rowsWithQueuePosition, err := q.getProvisionerJobsByIDsWithQueuePositionLocked(ctx, nil)
|
||||
rowsWithQueuePosition, err := q.getProvisionerJobsByIDsWithQueuePositionLockedGlobalQueue(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -5003,9 +5237,9 @@ func (q *FakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg datab
|
||||
}
|
||||
|
||||
var row database.GetTemplateAverageBuildTimeRow
|
||||
row.Delete50, row.Delete95 = tryPercentile(deleteTimes, 50), tryPercentile(deleteTimes, 95)
|
||||
row.Stop50, row.Stop95 = tryPercentile(stopTimes, 50), tryPercentile(stopTimes, 95)
|
||||
row.Start50, row.Start95 = tryPercentile(startTimes, 50), tryPercentile(startTimes, 95)
|
||||
row.Delete50, row.Delete95 = tryPercentileDisc(deleteTimes, 50), tryPercentileDisc(deleteTimes, 95)
|
||||
row.Stop50, row.Stop95 = tryPercentileDisc(stopTimes, 50), tryPercentileDisc(stopTimes, 95)
|
||||
row.Start50, row.Start95 = tryPercentileDisc(startTimes, 50), tryPercentileDisc(startTimes, 95)
|
||||
return row, nil
|
||||
}
|
||||
|
||||
@ -6044,8 +6278,8 @@ func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.Get
|
||||
Username: user.Username,
|
||||
AvatarURL: user.AvatarURL,
|
||||
TemplateIDs: seenTemplatesByUserID[userID],
|
||||
WorkspaceConnectionLatency50: tryPercentile(latencies, 50),
|
||||
WorkspaceConnectionLatency95: tryPercentile(latencies, 95),
|
||||
WorkspaceConnectionLatency50: tryPercentileCont(latencies, 50),
|
||||
WorkspaceConnectionLatency95: tryPercentileCont(latencies, 95),
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
@ -6689,8 +6923,8 @@ func (q *FakeQuerier) GetWorkspaceAgentStats(_ context.Context, createdAfter tim
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stat.WorkspaceConnectionLatency50 = tryPercentile(latencies, 50)
|
||||
stat.WorkspaceConnectionLatency95 = tryPercentile(latencies, 95)
|
||||
stat.WorkspaceConnectionLatency50 = tryPercentileCont(latencies, 50)
|
||||
stat.WorkspaceConnectionLatency95 = tryPercentileCont(latencies, 95)
|
||||
statByAgent[stat.AgentID] = stat
|
||||
}
|
||||
|
||||
@ -6827,8 +7061,8 @@ func (q *FakeQuerier) GetWorkspaceAgentUsageStats(_ context.Context, createdAt t
|
||||
for key, latencies := range latestAgentLatencies {
|
||||
val, ok := latestAgentStats[key]
|
||||
if ok {
|
||||
val.WorkspaceConnectionLatency50 = tryPercentile(latencies, 50)
|
||||
val.WorkspaceConnectionLatency95 = tryPercentile(latencies, 95)
|
||||
val.WorkspaceConnectionLatency50 = tryPercentileCont(latencies, 50)
|
||||
val.WorkspaceConnectionLatency95 = tryPercentileCont(latencies, 95)
|
||||
}
|
||||
latestAgentStats[key] = val
|
||||
}
|
||||
@ -6938,7 +7172,7 @@ func (q *FakeQuerier) GetWorkspaceAgentUsageStatsAndLabels(_ context.Context, cr
|
||||
}
|
||||
// WHERE usage = true AND created_at > now() - '1 minute'::interval
|
||||
// GROUP BY user_id, agent_id, workspace_id
|
||||
if agentStat.Usage && agentStat.CreatedAt.After(time.Now().Add(-time.Minute)) {
|
||||
if agentStat.Usage && agentStat.CreatedAt.After(dbtime.Now().Add(-time.Minute)) {
|
||||
val, ok := latestAgentStats[key]
|
||||
if !ok {
|
||||
latestAgentStats[key] = agentStat
|
||||
@ -7977,6 +8211,30 @@ func (q *FakeQuerier) InsertGroupMember(_ context.Context, arg database.InsertGr
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) InsertInboxNotification(_ context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.InboxNotification{}, err
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
notification := database.InboxNotification{
|
||||
ID: arg.ID,
|
||||
UserID: arg.UserID,
|
||||
TemplateID: arg.TemplateID,
|
||||
Targets: arg.Targets,
|
||||
Title: arg.Title,
|
||||
Content: arg.Content,
|
||||
Icon: arg.Icon,
|
||||
Actions: arg.Actions,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
q.InboxNotification = append(q.InboxNotification, notification)
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) InsertLicense(
|
||||
_ context.Context, arg database.InsertLicenseParams,
|
||||
) (database.License, error) {
|
||||
@ -9700,6 +9958,24 @@ func (q *FakeQuerier) UpdateInactiveUsersToDormant(_ context.Context, params dat
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) UpdateInboxNotificationReadStatus(_ context.Context, arg database.UpdateInboxNotificationReadStatusParams) error {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
for i := range q.InboxNotification {
|
||||
if q.InboxNotification[i].ID == arg.ID {
|
||||
q.InboxNotification[i].ReadAt = arg.ReadAt
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.OrganizationMember{}, err
|
||||
|
@ -2,11 +2,11 @@ package dbmetrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
|
@ -5,11 +5,11 @@ package dbmetrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
@ -185,6 +185,13 @@ func (m queryMetricsStore) CleanTailnetTunnels(ctx context.Context) error {
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CountUnreadInboxNotificationsByUserID(ctx, userID)
|
||||
m.queryLatencies.WithLabelValues("CountUnreadInboxNotificationsByUserID").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CustomRoles(ctx, arg)
|
||||
@ -451,6 +458,13 @@ func (m queryMetricsStore) FetchMemoryResourceMonitorsByAgentID(ctx context.Cont
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.FetchMemoryResourceMonitorsUpdatedAfter(ctx, updatedAt)
|
||||
m.queryLatencies.WithLabelValues("FetchMemoryResourceMonitorsUpdatedAfter").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.FetchNewMessageMetadata(ctx, arg)
|
||||
@ -465,6 +479,13 @@ func (m queryMetricsStore) FetchVolumesResourceMonitorsByAgentID(ctx context.Con
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt)
|
||||
m.queryLatencies.WithLabelValues("FetchVolumesResourceMonitorsUpdatedAfter").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
|
||||
start := time.Now()
|
||||
apiKey, err := m.s.GetAPIKeyByID(ctx, id)
|
||||
@ -717,6 +738,13 @@ func (m queryMetricsStore) GetFileTemplates(ctx context.Context, fileID uuid.UUI
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetFilteredInboxNotificationsByUserID(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetFilteredInboxNotificationsByUserID").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) {
|
||||
start := time.Now()
|
||||
key, err := m.s.GetGitSSHKey(ctx, userID)
|
||||
@ -780,6 +808,20 @@ func (m queryMetricsStore) GetHungProvisionerJobs(ctx context.Context, hungSince
|
||||
return jobs, err
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetInboxNotificationByID(ctx, id)
|
||||
m.queryLatencies.WithLabelValues("GetInboxNotificationByID").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetInboxNotificationsByUserID(ctx context.Context, userID database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetInboxNotificationsByUserID(ctx, userID)
|
||||
m.queryLatencies.WithLabelValues("GetInboxNotificationsByUserID").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetJFrogXrayScanByWorkspaceAndAgentID(ctx, arg)
|
||||
@ -1914,6 +1956,13 @@ func (m queryMetricsStore) InsertGroupMember(ctx context.Context, arg database.I
|
||||
return err
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertInboxNotification(ctx context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.InsertInboxNotification(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("InsertInboxNotification").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertLicense(ctx context.Context, arg database.InsertLicenseParams) (database.License, error) {
|
||||
start := time.Now()
|
||||
license, err := m.s.InsertLicense(ctx, arg)
|
||||
@ -2376,6 +2425,13 @@ func (m queryMetricsStore) UpdateInactiveUsersToDormant(ctx context.Context, las
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateInboxNotificationReadStatus(ctx context.Context, arg database.UpdateInboxNotificationReadStatusParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpdateInboxNotificationReadStatus(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateInboxNotificationReadStatus").Observe(time.Since(start).Seconds())
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
|
||||
start := time.Now()
|
||||
member, err := m.s.UpdateMemberRoles(ctx, arg)
|
||||
|
@ -247,6 +247,21 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(ctx any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanTailnetTunnels", reflect.TypeOf((*MockStore)(nil).CleanTailnetTunnels), ctx)
|
||||
}
|
||||
|
||||
// CountUnreadInboxNotificationsByUserID mocks base method.
|
||||
func (m *MockStore) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CountUnreadInboxNotificationsByUserID", ctx, userID)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CountUnreadInboxNotificationsByUserID indicates an expected call of CountUnreadInboxNotificationsByUserID.
|
||||
func (mr *MockStoreMockRecorder) CountUnreadInboxNotificationsByUserID(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountUnreadInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).CountUnreadInboxNotificationsByUserID), ctx, userID)
|
||||
}
|
||||
|
||||
// CustomRoles mocks base method.
|
||||
func (m *MockStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -787,6 +802,21 @@ func (mr *MockStoreMockRecorder) FetchMemoryResourceMonitorsByAgentID(ctx, agent
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchMemoryResourceMonitorsByAgentID", reflect.TypeOf((*MockStore)(nil).FetchMemoryResourceMonitorsByAgentID), ctx, agentID)
|
||||
}
|
||||
|
||||
// FetchMemoryResourceMonitorsUpdatedAfter mocks base method.
|
||||
func (m *MockStore) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentMemoryResourceMonitor, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "FetchMemoryResourceMonitorsUpdatedAfter", ctx, updatedAt)
|
||||
ret0, _ := ret[0].([]database.WorkspaceAgentMemoryResourceMonitor)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// FetchMemoryResourceMonitorsUpdatedAfter indicates an expected call of FetchMemoryResourceMonitorsUpdatedAfter.
|
||||
func (mr *MockStoreMockRecorder) FetchMemoryResourceMonitorsUpdatedAfter(ctx, updatedAt any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchMemoryResourceMonitorsUpdatedAfter", reflect.TypeOf((*MockStore)(nil).FetchMemoryResourceMonitorsUpdatedAfter), ctx, updatedAt)
|
||||
}
|
||||
|
||||
// FetchNewMessageMetadata mocks base method.
|
||||
func (m *MockStore) FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -817,6 +847,21 @@ func (mr *MockStoreMockRecorder) FetchVolumesResourceMonitorsByAgentID(ctx, agen
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchVolumesResourceMonitorsByAgentID", reflect.TypeOf((*MockStore)(nil).FetchVolumesResourceMonitorsByAgentID), ctx, agentID)
|
||||
}
|
||||
|
||||
// FetchVolumesResourceMonitorsUpdatedAfter mocks base method.
|
||||
func (m *MockStore) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]database.WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "FetchVolumesResourceMonitorsUpdatedAfter", ctx, updatedAt)
|
||||
ret0, _ := ret[0].([]database.WorkspaceAgentVolumeResourceMonitor)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// FetchVolumesResourceMonitorsUpdatedAfter indicates an expected call of FetchVolumesResourceMonitorsUpdatedAfter.
|
||||
func (mr *MockStoreMockRecorder) FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchVolumesResourceMonitorsUpdatedAfter", reflect.TypeOf((*MockStore)(nil).FetchVolumesResourceMonitorsUpdatedAfter), ctx, updatedAt)
|
||||
}
|
||||
|
||||
// GetAPIKeyByID mocks base method.
|
||||
func (m *MockStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -1432,6 +1477,21 @@ func (mr *MockStoreMockRecorder) GetFileTemplates(ctx, fileID any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileTemplates", reflect.TypeOf((*MockStore)(nil).GetFileTemplates), ctx, fileID)
|
||||
}
|
||||
|
||||
// GetFilteredInboxNotificationsByUserID mocks base method.
|
||||
func (m *MockStore) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetFilteredInboxNotificationsByUserID", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.InboxNotification)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetFilteredInboxNotificationsByUserID indicates an expected call of GetFilteredInboxNotificationsByUserID.
|
||||
func (mr *MockStoreMockRecorder) GetFilteredInboxNotificationsByUserID(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFilteredInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).GetFilteredInboxNotificationsByUserID), ctx, arg)
|
||||
}
|
||||
|
||||
// GetGitSSHKey mocks base method.
|
||||
func (m *MockStore) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -1567,6 +1627,36 @@ func (mr *MockStoreMockRecorder) GetHungProvisionerJobs(ctx, updatedAt any) *gom
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHungProvisionerJobs", reflect.TypeOf((*MockStore)(nil).GetHungProvisionerJobs), ctx, updatedAt)
|
||||
}
|
||||
|
||||
// GetInboxNotificationByID mocks base method.
|
||||
func (m *MockStore) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetInboxNotificationByID", ctx, id)
|
||||
ret0, _ := ret[0].(database.InboxNotification)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetInboxNotificationByID indicates an expected call of GetInboxNotificationByID.
|
||||
func (mr *MockStoreMockRecorder) GetInboxNotificationByID(ctx, id any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInboxNotificationByID", reflect.TypeOf((*MockStore)(nil).GetInboxNotificationByID), ctx, id)
|
||||
}
|
||||
|
||||
// GetInboxNotificationsByUserID mocks base method.
|
||||
func (m *MockStore) GetInboxNotificationsByUserID(ctx context.Context, arg database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetInboxNotificationsByUserID", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.InboxNotification)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetInboxNotificationsByUserID indicates an expected call of GetInboxNotificationsByUserID.
|
||||
func (mr *MockStoreMockRecorder) GetInboxNotificationsByUserID(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).GetInboxNotificationsByUserID), ctx, arg)
|
||||
}
|
||||
|
||||
// GetJFrogXrayScanByWorkspaceAndAgentID mocks base method.
|
||||
func (m *MockStore) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -4037,6 +4127,21 @@ func (mr *MockStoreMockRecorder) InsertGroupMember(ctx, arg any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertGroupMember", reflect.TypeOf((*MockStore)(nil).InsertGroupMember), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertInboxNotification mocks base method.
|
||||
func (m *MockStore) InsertInboxNotification(ctx context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "InsertInboxNotification", ctx, arg)
|
||||
ret0, _ := ret[0].(database.InboxNotification)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// InsertInboxNotification indicates an expected call of InsertInboxNotification.
|
||||
func (mr *MockStoreMockRecorder) InsertInboxNotification(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertInboxNotification", reflect.TypeOf((*MockStore)(nil).InsertInboxNotification), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertLicense mocks base method.
|
||||
func (m *MockStore) InsertLicense(ctx context.Context, arg database.InsertLicenseParams) (database.License, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -5041,6 +5146,20 @@ func (mr *MockStoreMockRecorder) UpdateInactiveUsersToDormant(ctx, arg any) *gom
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateInactiveUsersToDormant", reflect.TypeOf((*MockStore)(nil).UpdateInactiveUsersToDormant), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateInboxNotificationReadStatus mocks base method.
|
||||
func (m *MockStore) UpdateInboxNotificationReadStatus(ctx context.Context, arg database.UpdateInboxNotificationReadStatusParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateInboxNotificationReadStatus", ctx, arg)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpdateInboxNotificationReadStatus indicates an expected call of UpdateInboxNotificationReadStatus.
|
||||
func (mr *MockStoreMockRecorder) UpdateInboxNotificationReadStatus(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateInboxNotificationReadStatus", reflect.TypeOf((*MockStore)(nil).UpdateInboxNotificationReadStatus), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateMemberRoles mocks base method.
|
||||
func (m *MockStore) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -14,7 +15,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
34
coderd/database/dump.sql
generated
34
coderd/database/dump.sql
generated
@ -66,6 +66,12 @@ CREATE TYPE group_source AS ENUM (
|
||||
'oidc'
|
||||
);
|
||||
|
||||
CREATE TYPE inbox_notification_read_status AS ENUM (
|
||||
'all',
|
||||
'unread',
|
||||
'read'
|
||||
);
|
||||
|
||||
CREATE TYPE log_level AS ENUM (
|
||||
'trace',
|
||||
'debug',
|
||||
@ -902,6 +908,19 @@ CREATE VIEW group_members_expanded AS
|
||||
|
||||
COMMENT ON VIEW group_members_expanded IS 'Joins group members with user information, organization ID, group name. Includes both regular group members and organization members (as part of the "Everyone" group).';
|
||||
|
||||
CREATE TABLE inbox_notifications (
|
||||
id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
template_id uuid NOT NULL,
|
||||
targets uuid[],
|
||||
title text NOT NULL,
|
||||
content text NOT NULL,
|
||||
icon text NOT NULL,
|
||||
actions jsonb NOT NULL,
|
||||
read_at timestamp with time zone,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE jfrog_xray_scans (
|
||||
agent_id uuid NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
@ -2132,6 +2151,9 @@ ALTER TABLE ONLY groups
|
||||
ALTER TABLE ONLY groups
|
||||
ADD CONSTRAINT groups_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY inbox_notifications
|
||||
ADD CONSTRAINT inbox_notifications_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY jfrog_xray_scans
|
||||
ADD CONSTRAINT jfrog_xray_scans_pkey PRIMARY KEY (agent_id, workspace_id);
|
||||
|
||||
@ -2368,6 +2390,10 @@ CREATE INDEX idx_custom_roles_id ON custom_roles USING btree (id);
|
||||
|
||||
CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name));
|
||||
|
||||
CREATE INDEX idx_inbox_notifications_user_id_read_at ON inbox_notifications USING btree (user_id, read_at);
|
||||
|
||||
CREATE INDEX idx_inbox_notifications_user_id_template_id_targets ON inbox_notifications USING btree (user_id, template_id, targets);
|
||||
|
||||
CREATE INDEX idx_notification_messages_status ON notification_messages USING btree (status);
|
||||
|
||||
CREATE INDEX idx_organization_member_organization_id_uuid ON organization_members USING btree (organization_id);
|
||||
@ -2380,6 +2406,8 @@ CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_da
|
||||
|
||||
COMMENT ON INDEX idx_provisioner_daemons_org_name_owner_key IS 'Allow unique provisioner daemon names by organization and user';
|
||||
|
||||
CREATE INDEX idx_provisioner_jobs_status ON provisioner_jobs USING btree (job_status);
|
||||
|
||||
CREATE INDEX idx_tailnet_agents_coordinator ON tailnet_agents USING btree (coordinator_id);
|
||||
|
||||
CREATE INDEX idx_tailnet_clients_coordinator ON tailnet_clients USING btree (coordinator_id);
|
||||
@ -2650,6 +2678,12 @@ ALTER TABLE ONLY group_members
|
||||
ALTER TABLE ONLY groups
|
||||
ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY inbox_notifications
|
||||
ADD CONSTRAINT inbox_notifications_template_id_fkey FOREIGN KEY (template_id) REFERENCES notification_templates(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY inbox_notifications
|
||||
ADD CONSTRAINT inbox_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY jfrog_xray_scans
|
||||
ADD CONSTRAINT jfrog_xray_scans_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
|
||||
|
@ -14,6 +14,8 @@ const (
|
||||
ForeignKeyGroupMembersGroupID ForeignKeyConstraint = "group_members_group_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
|
||||
ForeignKeyGroupMembersUserID ForeignKeyConstraint = "group_members_user_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyGroupsOrganizationID ForeignKeyConstraint = "groups_organization_id_fkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
ForeignKeyInboxNotificationsTemplateID ForeignKeyConstraint = "inbox_notifications_template_id_fkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_template_id_fkey FOREIGN KEY (template_id) REFERENCES notification_templates(id) ON DELETE CASCADE;
|
||||
ForeignKeyInboxNotificationsUserID ForeignKeyConstraint = "inbox_notifications_user_id_fkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyJfrogXrayScansAgentID ForeignKeyConstraint = "jfrog_xray_scans_agent_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
ForeignKeyJfrogXrayScansWorkspaceID ForeignKeyConstraint = "jfrog_xray_scans_workspace_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
|
||||
ForeignKeyNotificationMessagesNotificationTemplateID ForeignKeyConstraint = "notification_messages_notification_template_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE;
|
||||
|
@ -5,11 +5,11 @@ import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// TestCustomQueriesSynced makes sure the manual custom queries in modelqueries.go
|
||||
|
@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS inbox_notifications;
|
||||
|
||||
DROP TYPE IF EXISTS inbox_notification_read_status;
|
17
coderd/database/migrations/000297_notifications_inbox.up.sql
Normal file
17
coderd/database/migrations/000297_notifications_inbox.up.sql
Normal file
@ -0,0 +1,17 @@
|
||||
CREATE TYPE inbox_notification_read_status AS ENUM ('all', 'unread', 'read');
|
||||
|
||||
CREATE TABLE inbox_notifications (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
template_id UUID NOT NULL REFERENCES notification_templates(id) ON DELETE CASCADE,
|
||||
targets UUID[],
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
actions JSONB NOT NULL,
|
||||
read_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_inbox_notifications_user_id_read_at ON inbox_notifications(user_id, read_at);
|
||||
CREATE INDEX idx_inbox_notifications_user_id_template_id_targets ON inbox_notifications(user_id, template_id, targets);
|
@ -0,0 +1 @@
|
||||
DROP INDEX idx_provisioner_jobs_status;
|
@ -0,0 +1 @@
|
||||
CREATE INDEX idx_provisioner_jobs_status ON provisioner_jobs USING btree (job_status);
|
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@ -17,7 +18,6 @@ import (
|
||||
"github.com/lib/pq"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
|
25
coderd/database/migrations/testdata/fixtures/000297_notifications_inbox.up.sql
vendored
Normal file
25
coderd/database/migrations/testdata/fixtures/000297_notifications_inbox.up.sql
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
INSERT INTO
|
||||
inbox_notifications (
|
||||
id,
|
||||
user_id,
|
||||
template_id,
|
||||
targets,
|
||||
title,
|
||||
content,
|
||||
icon,
|
||||
actions,
|
||||
read_at,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
'68b396aa-7f53-4bf1-b8d8-4cbf5fa244e5', -- uuid
|
||||
'5755e622-fadd-44ca-98da-5df070491844', -- uuid
|
||||
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', -- uuid
|
||||
ARRAY[]::UUID[], -- uuid[]
|
||||
'Test Notification',
|
||||
'This is a test notification',
|
||||
'https://test.coder.com/favicon.ico',
|
||||
'{}',
|
||||
'2025-01-01 00:00:00',
|
||||
'2025-01-01 00:00:00'
|
||||
);
|
@ -168,6 +168,12 @@ func (TemplateVersion) RBACObject(template Template) rbac.Object {
|
||||
return template.RBACObject()
|
||||
}
|
||||
|
||||
func (i InboxNotification) RBACObject() rbac.Object {
|
||||
return rbac.ResourceInboxNotification.
|
||||
WithID(i.ID).
|
||||
WithOwner(i.UserID.String())
|
||||
}
|
||||
|
||||
// RBACObjectNoTemplate is for orphaned template versions.
|
||||
func (v TemplateVersion) RBACObjectNoTemplate() rbac.Object {
|
||||
return rbac.ResourceTemplate.InOrg(v.OrganizationID)
|
||||
|
@ -543,6 +543,67 @@ func AllGroupSourceValues() []GroupSource {
|
||||
}
|
||||
}
|
||||
|
||||
type InboxNotificationReadStatus string
|
||||
|
||||
const (
|
||||
InboxNotificationReadStatusAll InboxNotificationReadStatus = "all"
|
||||
InboxNotificationReadStatusUnread InboxNotificationReadStatus = "unread"
|
||||
InboxNotificationReadStatusRead InboxNotificationReadStatus = "read"
|
||||
)
|
||||
|
||||
func (e *InboxNotificationReadStatus) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = InboxNotificationReadStatus(s)
|
||||
case string:
|
||||
*e = InboxNotificationReadStatus(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for InboxNotificationReadStatus: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullInboxNotificationReadStatus struct {
|
||||
InboxNotificationReadStatus InboxNotificationReadStatus `json:"inbox_notification_read_status"`
|
||||
Valid bool `json:"valid"` // Valid is true if InboxNotificationReadStatus is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullInboxNotificationReadStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.InboxNotificationReadStatus, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.InboxNotificationReadStatus.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullInboxNotificationReadStatus) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.InboxNotificationReadStatus), nil
|
||||
}
|
||||
|
||||
func (e InboxNotificationReadStatus) Valid() bool {
|
||||
switch e {
|
||||
case InboxNotificationReadStatusAll,
|
||||
InboxNotificationReadStatusUnread,
|
||||
InboxNotificationReadStatusRead:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func AllInboxNotificationReadStatusValues() []InboxNotificationReadStatus {
|
||||
return []InboxNotificationReadStatus{
|
||||
InboxNotificationReadStatusAll,
|
||||
InboxNotificationReadStatusUnread,
|
||||
InboxNotificationReadStatusRead,
|
||||
}
|
||||
}
|
||||
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
@ -2557,6 +2618,19 @@ type GroupMemberTable struct {
|
||||
GroupID uuid.UUID `db:"group_id" json:"group_id"`
|
||||
}
|
||||
|
||||
type InboxNotification struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
|
||||
Targets []uuid.UUID `db:"targets" json:"targets"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Content string `db:"content" json:"content"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
Actions json.RawMessage `db:"actions" json:"actions"`
|
||||
ReadAt sql.NullTime `db:"read_at" json:"read_at"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type JfrogXrayScan struct {
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
|
@ -65,6 +65,7 @@ type sqlcQuerier interface {
|
||||
CleanTailnetCoordinators(ctx context.Context) error
|
||||
CleanTailnetLostPeers(ctx context.Context) error
|
||||
CleanTailnetTunnels(ctx context.Context) error
|
||||
CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||
CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error)
|
||||
DeleteAPIKeyByID(ctx context.Context, id string) error
|
||||
DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error
|
||||
@ -114,9 +115,11 @@ type sqlcQuerier interface {
|
||||
EnqueueNotificationMessage(ctx context.Context, arg EnqueueNotificationMessageParams) error
|
||||
FavoriteWorkspace(ctx context.Context, id uuid.UUID) error
|
||||
FetchMemoryResourceMonitorsByAgentID(ctx context.Context, agentID uuid.UUID) (WorkspaceAgentMemoryResourceMonitor, error)
|
||||
FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentMemoryResourceMonitor, error)
|
||||
// This is used to build up the notification_message's JSON payload.
|
||||
FetchNewMessageMetadata(ctx context.Context, arg FetchNewMessageMetadataParams) (FetchNewMessageMetadataRow, error)
|
||||
FetchVolumesResourceMonitorsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceAgentVolumeResourceMonitor, error)
|
||||
FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentVolumeResourceMonitor, error)
|
||||
GetAPIKeyByID(ctx context.Context, id string) (APIKey, error)
|
||||
// there is no unique constraint on empty token names
|
||||
GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error)
|
||||
@ -160,6 +163,14 @@ type sqlcQuerier interface {
|
||||
GetFileByID(ctx context.Context, id uuid.UUID) (File, error)
|
||||
// Get all templates that use a file.
|
||||
GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]GetFileTemplatesRow, error)
|
||||
// Fetches inbox notifications for a user filtered by templates and targets
|
||||
// param user_id: The user ID
|
||||
// param templates: The template IDs to filter by - the template_id = ANY(@templates::UUID[]) condition checks if the template_id is in the @templates array
|
||||
// param targets: The target IDs to filter by - the targets @> COALESCE(@targets, ARRAY[]::UUID[]) condition checks if the targets array (from the DB) contains all the elements in the @targets array
|
||||
// param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
|
||||
// param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
|
||||
// param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
|
||||
GetFilteredInboxNotificationsByUserID(ctx context.Context, arg GetFilteredInboxNotificationsByUserIDParams) ([]InboxNotification, error)
|
||||
GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error)
|
||||
GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error)
|
||||
GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error)
|
||||
@ -172,6 +183,13 @@ type sqlcQuerier interface {
|
||||
GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetGroupsRow, error)
|
||||
GetHealthSettings(ctx context.Context) (string, error)
|
||||
GetHungProvisionerJobs(ctx context.Context, updatedAt time.Time) ([]ProvisionerJob, error)
|
||||
GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (InboxNotification, error)
|
||||
// Fetches inbox notifications for a user filtered by templates and targets
|
||||
// param user_id: The user ID
|
||||
// param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
|
||||
// param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
|
||||
// param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
|
||||
GetInboxNotificationsByUserID(ctx context.Context, arg GetInboxNotificationsByUserIDParams) ([]InboxNotification, error)
|
||||
GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg GetJFrogXrayScanByWorkspaceAndAgentIDParams) (JfrogXrayScan, error)
|
||||
GetLastUpdateCheck(ctx context.Context) (string, error)
|
||||
GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error)
|
||||
@ -402,6 +420,7 @@ type sqlcQuerier interface {
|
||||
InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error)
|
||||
InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error)
|
||||
InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error
|
||||
InsertInboxNotification(ctx context.Context, arg InsertInboxNotificationParams) (InboxNotification, error)
|
||||
InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error)
|
||||
InsertMemoryResourceMonitor(ctx context.Context, arg InsertMemoryResourceMonitorParams) (WorkspaceAgentMemoryResourceMonitor, error)
|
||||
// Inserts any group by name that does not exist. All new groups are given
|
||||
@ -486,6 +505,7 @@ type sqlcQuerier interface {
|
||||
UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error)
|
||||
UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error)
|
||||
UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error)
|
||||
UpdateInboxNotificationReadStatus(ctx context.Context, arg UpdateInboxNotificationReadStatusParams) error
|
||||
UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error)
|
||||
UpdateMemoryResourceMonitor(ctx context.Context, arg UpdateMemoryResourceMonitorParams) error
|
||||
UpdateNotificationTemplateMethodByID(ctx context.Context, arg UpdateNotificationTemplateMethodByIDParams) (NotificationTemplate, error)
|
||||
|
@ -1257,6 +1257,15 @@ func TestQueuePosition(t *testing.T) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
// Create default provisioner daemon:
|
||||
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
|
||||
Name: "default_provisioner",
|
||||
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
// Ensure the `tags` field is NOT NULL for the default provisioner;
|
||||
// otherwise, it won't be able to pick up any jobs.
|
||||
Tags: database.StringMap{},
|
||||
})
|
||||
|
||||
queued, err := db.GetProvisionerJobsByIDsWithQueuePosition(ctx, jobIDs)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, queued, jobCount)
|
||||
@ -2159,6 +2168,307 @@ func TestExpectOne(t *testing.T) {
|
||||
|
||||
func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := dbtime.Now()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
jobTags []database.StringMap
|
||||
daemonTags []database.StringMap
|
||||
queueSizes []int64
|
||||
queuePositions []int64
|
||||
// GetProvisionerJobsByIDsWithQueuePosition takes jobIDs as a parameter.
|
||||
// If skipJobIDs is empty, all jobs are passed to the function; otherwise, the specified jobs are skipped.
|
||||
// NOTE: Skipping job IDs means they will be excluded from the result,
|
||||
// but this should not affect the queue position or queue size of other jobs.
|
||||
skipJobIDs map[int]struct{}
|
||||
}{
|
||||
// Baseline test case
|
||||
{
|
||||
name: "test-case-1",
|
||||
jobTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "c": "3"},
|
||||
},
|
||||
daemonTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
},
|
||||
queueSizes: []int64{2, 2, 0},
|
||||
queuePositions: []int64{1, 1, 0},
|
||||
},
|
||||
// Includes an additional provisioner
|
||||
{
|
||||
name: "test-case-2",
|
||||
jobTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "c": "3"},
|
||||
},
|
||||
daemonTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "b": "2", "c": "3"},
|
||||
},
|
||||
queueSizes: []int64{3, 3, 3},
|
||||
queuePositions: []int64{1, 1, 3},
|
||||
},
|
||||
// Skips job at index 0
|
||||
{
|
||||
name: "test-case-3",
|
||||
jobTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "c": "3"},
|
||||
},
|
||||
daemonTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "b": "2", "c": "3"},
|
||||
},
|
||||
queueSizes: []int64{3, 3},
|
||||
queuePositions: []int64{1, 3},
|
||||
skipJobIDs: map[int]struct{}{
|
||||
0: {},
|
||||
},
|
||||
},
|
||||
// Skips job at index 1
|
||||
{
|
||||
name: "test-case-4",
|
||||
jobTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "c": "3"},
|
||||
},
|
||||
daemonTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "b": "2", "c": "3"},
|
||||
},
|
||||
queueSizes: []int64{3, 3},
|
||||
queuePositions: []int64{1, 3},
|
||||
skipJobIDs: map[int]struct{}{
|
||||
1: {},
|
||||
},
|
||||
},
|
||||
// Skips job at index 2
|
||||
{
|
||||
name: "test-case-5",
|
||||
jobTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "c": "3"},
|
||||
},
|
||||
daemonTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "b": "2", "c": "3"},
|
||||
},
|
||||
queueSizes: []int64{3, 3},
|
||||
queuePositions: []int64{1, 1},
|
||||
skipJobIDs: map[int]struct{}{
|
||||
2: {},
|
||||
},
|
||||
},
|
||||
// Skips jobs at indexes 0 and 2
|
||||
{
|
||||
name: "test-case-6",
|
||||
jobTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "c": "3"},
|
||||
},
|
||||
daemonTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "b": "2", "c": "3"},
|
||||
},
|
||||
queueSizes: []int64{3},
|
||||
queuePositions: []int64{1},
|
||||
skipJobIDs: map[int]struct{}{
|
||||
0: {},
|
||||
2: {},
|
||||
},
|
||||
},
|
||||
// Includes two additional jobs that any provisioner can execute.
|
||||
{
|
||||
name: "test-case-7",
|
||||
jobTags: []database.StringMap{
|
||||
{},
|
||||
{},
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "c": "3"},
|
||||
},
|
||||
daemonTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "b": "2", "c": "3"},
|
||||
},
|
||||
queueSizes: []int64{5, 5, 5, 5, 5},
|
||||
queuePositions: []int64{1, 2, 3, 3, 5},
|
||||
},
|
||||
// Includes two additional jobs that any provisioner can execute, but they are intentionally skipped.
|
||||
{
|
||||
name: "test-case-8",
|
||||
jobTags: []database.StringMap{
|
||||
{},
|
||||
{},
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "c": "3"},
|
||||
},
|
||||
daemonTags: []database.StringMap{
|
||||
{"a": "1", "b": "2"},
|
||||
{"a": "1"},
|
||||
{"a": "1", "b": "2", "c": "3"},
|
||||
},
|
||||
queueSizes: []int64{5, 5, 5},
|
||||
queuePositions: []int64{3, 3, 5},
|
||||
skipJobIDs: map[int]struct{}{
|
||||
0: {},
|
||||
1: {},
|
||||
},
|
||||
},
|
||||
// N jobs (1 job with 0 tags) & 0 provisioners exist
|
||||
{
|
||||
name: "test-case-9",
|
||||
jobTags: []database.StringMap{
|
||||
{},
|
||||
{"a": "1"},
|
||||
{"b": "2"},
|
||||
},
|
||||
daemonTags: []database.StringMap{},
|
||||
queueSizes: []int64{0, 0, 0},
|
||||
queuePositions: []int64{0, 0, 0},
|
||||
},
|
||||
// N jobs (1 job with 0 tags) & N provisioners
|
||||
{
|
||||
name: "test-case-10",
|
||||
jobTags: []database.StringMap{
|
||||
{},
|
||||
{"a": "1"},
|
||||
{"b": "2"},
|
||||
},
|
||||
daemonTags: []database.StringMap{
|
||||
{},
|
||||
{"a": "1"},
|
||||
{"b": "2"},
|
||||
},
|
||||
queueSizes: []int64{2, 2, 2},
|
||||
queuePositions: []int64{1, 2, 2},
|
||||
},
|
||||
// (N + 1) jobs (1 job with 0 tags) & N provisioners
|
||||
// 1 job not matching any provisioner (first in the list)
|
||||
{
|
||||
name: "test-case-11",
|
||||
jobTags: []database.StringMap{
|
||||
{"c": "3"},
|
||||
{},
|
||||
{"a": "1"},
|
||||
{"b": "2"},
|
||||
},
|
||||
daemonTags: []database.StringMap{
|
||||
{},
|
||||
{"a": "1"},
|
||||
{"b": "2"},
|
||||
},
|
||||
queueSizes: []int64{0, 2, 2, 2},
|
||||
queuePositions: []int64{0, 1, 2, 2},
|
||||
},
|
||||
// 0 jobs & 0 provisioners
|
||||
{
|
||||
name: "test-case-12",
|
||||
jobTags: []database.StringMap{},
|
||||
daemonTags: []database.StringMap{},
|
||||
queueSizes: nil, // TODO(yevhenii): should it be empty array instead?
|
||||
queuePositions: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc // Capture loop variable to avoid data races
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
// Create provisioner jobs based on provided tags:
|
||||
allJobs := make([]database.ProvisionerJob, len(tc.jobTags))
|
||||
for idx, tags := range tc.jobTags {
|
||||
// Make sure jobs are stored in correct order, first job should have the earliest createdAt timestamp.
|
||||
// Example for 3 jobs:
|
||||
// job_1 createdAt: now - 3 minutes
|
||||
// job_2 createdAt: now - 2 minutes
|
||||
// job_3 createdAt: now - 1 minute
|
||||
timeOffsetInMinutes := len(tc.jobTags) - idx
|
||||
timeOffset := time.Duration(timeOffsetInMinutes) * time.Minute
|
||||
createdAt := now.Add(-timeOffset)
|
||||
|
||||
allJobs[idx] = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||
CreatedAt: createdAt,
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
|
||||
// Create provisioner daemons based on provided tags:
|
||||
for idx, tags := range tc.daemonTags {
|
||||
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
|
||||
Name: fmt.Sprintf("prov_%v", idx),
|
||||
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
|
||||
// Assert invariant: the jobs are in pending status
|
||||
for idx, job := range allJobs {
|
||||
require.Equal(t, database.ProvisionerJobStatusPending, job.JobStatus, "expected job %d to have status %s", idx, database.ProvisionerJobStatusPending)
|
||||
}
|
||||
|
||||
filteredJobs := make([]database.ProvisionerJob, 0)
|
||||
filteredJobIDs := make([]uuid.UUID, 0)
|
||||
for idx, job := range allJobs {
|
||||
if _, skip := tc.skipJobIDs[idx]; skip {
|
||||
continue
|
||||
}
|
||||
|
||||
filteredJobs = append(filteredJobs, job)
|
||||
filteredJobIDs = append(filteredJobIDs, job.ID)
|
||||
}
|
||||
|
||||
// When: we fetch the jobs by their IDs
|
||||
actualJobs, err := db.GetProvisionerJobsByIDsWithQueuePosition(ctx, filteredJobIDs)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, actualJobs, len(filteredJobs), "should return all unskipped jobs")
|
||||
|
||||
// Then: the jobs should be returned in the correct order (sorted by createdAt)
|
||||
sort.Slice(filteredJobs, func(i, j int) bool {
|
||||
return filteredJobs[i].CreatedAt.Before(filteredJobs[j].CreatedAt)
|
||||
})
|
||||
for idx, job := range actualJobs {
|
||||
assert.EqualValues(t, filteredJobs[idx], job.ProvisionerJob)
|
||||
}
|
||||
|
||||
// Then: the queue size should be set correctly
|
||||
var queueSizes []int64
|
||||
for _, job := range actualJobs {
|
||||
queueSizes = append(queueSizes, job.QueueSize)
|
||||
}
|
||||
assert.EqualValues(t, tc.queueSizes, queueSizes, "expected queue positions to be set correctly")
|
||||
|
||||
// Then: the queue position should be set correctly:
|
||||
var queuePositions []int64
|
||||
for _, job := range actualJobs {
|
||||
queuePositions = append(queuePositions, job.QueuePosition)
|
||||
}
|
||||
assert.EqualValues(t, tc.queuePositions, queuePositions, "expected queue positions to be set correctly")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProvisionerJobsByIDsWithQueuePosition_MixedStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.SkipNow()
|
||||
}
|
||||
@ -2167,7 +2477,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
|
||||
now := dbtime.Now()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Given the following provisioner jobs:
|
||||
// Create the following provisioner jobs:
|
||||
allJobs := []database.ProvisionerJob{
|
||||
// Pending. This will be the last in the queue because
|
||||
// it was created most recently.
|
||||
@ -2177,6 +2487,9 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
|
||||
CanceledAt: sql.NullTime{},
|
||||
CompletedAt: sql.NullTime{},
|
||||
Error: sql.NullString{},
|
||||
// Ensure the `tags` field is NOT NULL for both provisioner jobs and provisioner daemons;
|
||||
// otherwise, provisioner daemons won't be able to pick up any jobs.
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
|
||||
// Another pending. This will come first in the queue
|
||||
@ -2187,6 +2500,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
|
||||
CanceledAt: sql.NullTime{},
|
||||
CompletedAt: sql.NullTime{},
|
||||
Error: sql.NullString{},
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
|
||||
// Running
|
||||
@ -2196,6 +2510,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
|
||||
CanceledAt: sql.NullTime{},
|
||||
CompletedAt: sql.NullTime{},
|
||||
Error: sql.NullString{},
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
|
||||
// Succeeded
|
||||
@ -2205,6 +2520,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
|
||||
CanceledAt: sql.NullTime{},
|
||||
CompletedAt: sql.NullTime{Valid: true, Time: now},
|
||||
Error: sql.NullString{},
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
|
||||
// Canceling
|
||||
@ -2214,6 +2530,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
|
||||
CanceledAt: sql.NullTime{Valid: true, Time: now},
|
||||
CompletedAt: sql.NullTime{},
|
||||
Error: sql.NullString{},
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
|
||||
// Canceled
|
||||
@ -2223,6 +2540,7 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
|
||||
CanceledAt: sql.NullTime{Valid: true, Time: now},
|
||||
CompletedAt: sql.NullTime{Valid: true, Time: now},
|
||||
Error: sql.NullString{},
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
|
||||
// Failed
|
||||
@ -2232,9 +2550,17 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
|
||||
CanceledAt: sql.NullTime{},
|
||||
CompletedAt: sql.NullTime{},
|
||||
Error: sql.NullString{String: "failed", Valid: true},
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
}
|
||||
|
||||
// Create default provisioner daemon:
|
||||
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
|
||||
Name: "default_provisioner",
|
||||
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
Tags: database.StringMap{},
|
||||
})
|
||||
|
||||
// Assert invariant: the jobs are in the expected order
|
||||
require.Len(t, allJobs, 7, "expected 7 jobs")
|
||||
for idx, status := range []database.ProvisionerJobStatus{
|
||||
@ -2259,22 +2585,123 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, actualJobs, len(allJobs), "should return all jobs")
|
||||
|
||||
// Then: the jobs should be returned in the correct order (by IDs in the input slice)
|
||||
// Then: the jobs should be returned in the correct order (sorted by createdAt)
|
||||
sort.Slice(allJobs, func(i, j int) bool {
|
||||
return allJobs[i].CreatedAt.Before(allJobs[j].CreatedAt)
|
||||
})
|
||||
for idx, job := range actualJobs {
|
||||
assert.EqualValues(t, allJobs[idx], job.ProvisionerJob)
|
||||
}
|
||||
|
||||
// Then: the queue size should be set correctly
|
||||
var queueSizes []int64
|
||||
for _, job := range actualJobs {
|
||||
assert.EqualValues(t, job.QueueSize, 2, "should have queue size 2")
|
||||
queueSizes = append(queueSizes, job.QueueSize)
|
||||
}
|
||||
assert.EqualValues(t, []int64{0, 0, 0, 0, 0, 2, 2}, queueSizes, "expected queue positions to be set correctly")
|
||||
|
||||
// Then: the queue position should be set correctly:
|
||||
var queuePositions []int64
|
||||
for _, job := range actualJobs {
|
||||
queuePositions = append(queuePositions, job.QueuePosition)
|
||||
}
|
||||
assert.EqualValues(t, []int64{2, 1, 0, 0, 0, 0, 0}, queuePositions, "expected queue positions to be set correctly")
|
||||
assert.EqualValues(t, []int64{0, 0, 0, 0, 0, 1, 2}, queuePositions, "expected queue positions to be set correctly")
|
||||
}
|
||||
|
||||
func TestGetProvisionerJobsByIDsWithQueuePosition_OrderValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
now := dbtime.Now()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Create the following provisioner jobs:
|
||||
allJobs := []database.ProvisionerJob{
|
||||
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||
CreatedAt: now.Add(-4 * time.Minute),
|
||||
// Ensure the `tags` field is NOT NULL for both provisioner jobs and provisioner daemons;
|
||||
// otherwise, provisioner daemons won't be able to pick up any jobs.
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
|
||||
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||
CreatedAt: now.Add(-5 * time.Minute),
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
|
||||
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||
CreatedAt: now.Add(-6 * time.Minute),
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
|
||||
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||
CreatedAt: now.Add(-3 * time.Minute),
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
|
||||
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||
CreatedAt: now.Add(-2 * time.Minute),
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
|
||||
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||
CreatedAt: now.Add(-1 * time.Minute),
|
||||
Tags: database.StringMap{},
|
||||
}),
|
||||
}
|
||||
|
||||
// Create default provisioner daemon:
|
||||
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
|
||||
Name: "default_provisioner",
|
||||
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
Tags: database.StringMap{},
|
||||
})
|
||||
|
||||
// Assert invariant: the jobs are in the expected order
|
||||
require.Len(t, allJobs, 6, "expected 7 jobs")
|
||||
for idx, status := range []database.ProvisionerJobStatus{
|
||||
database.ProvisionerJobStatusPending,
|
||||
database.ProvisionerJobStatusPending,
|
||||
database.ProvisionerJobStatusPending,
|
||||
database.ProvisionerJobStatusPending,
|
||||
database.ProvisionerJobStatusPending,
|
||||
database.ProvisionerJobStatusPending,
|
||||
} {
|
||||
require.Equal(t, status, allJobs[idx].JobStatus, "expected job %d to have status %s", idx, status)
|
||||
}
|
||||
|
||||
var jobIDs []uuid.UUID
|
||||
for _, job := range allJobs {
|
||||
jobIDs = append(jobIDs, job.ID)
|
||||
}
|
||||
|
||||
// When: we fetch the jobs by their IDs
|
||||
actualJobs, err := db.GetProvisionerJobsByIDsWithQueuePosition(ctx, jobIDs)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, actualJobs, len(allJobs), "should return all jobs")
|
||||
|
||||
// Then: the jobs should be returned in the correct order (sorted by createdAt)
|
||||
sort.Slice(allJobs, func(i, j int) bool {
|
||||
return allJobs[i].CreatedAt.Before(allJobs[j].CreatedAt)
|
||||
})
|
||||
for idx, job := range actualJobs {
|
||||
assert.EqualValues(t, allJobs[idx], job.ProvisionerJob)
|
||||
assert.EqualValues(t, allJobs[idx].CreatedAt, job.ProvisionerJob.CreatedAt)
|
||||
}
|
||||
|
||||
// Then: the queue size should be set correctly
|
||||
var queueSizes []int64
|
||||
for _, job := range actualJobs {
|
||||
queueSizes = append(queueSizes, job.QueueSize)
|
||||
}
|
||||
assert.EqualValues(t, []int64{6, 6, 6, 6, 6, 6}, queueSizes, "expected queue positions to be set correctly")
|
||||
|
||||
// Then: the queue position should be set correctly:
|
||||
var queuePositions []int64
|
||||
for _, job := range actualJobs {
|
||||
queuePositions = append(queuePositions, job.QueuePosition)
|
||||
}
|
||||
assert.EqualValues(t, []int64{1, 2, 3, 4, 5, 6}, queuePositions, "expected queue positions to be set correctly")
|
||||
}
|
||||
|
||||
func TestGroupRemovalTrigger(t *testing.T) {
|
||||
|
@ -4298,6 +4298,243 @@ func (q *sqlQuerier) UpsertNotificationReportGeneratorLog(ctx context.Context, a
|
||||
return err
|
||||
}
|
||||
|
||||
const countUnreadInboxNotificationsByUserID = `-- name: CountUnreadInboxNotificationsByUserID :one
|
||||
SELECT COUNT(*) FROM inbox_notifications WHERE user_id = $1 AND read_at IS NULL
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countUnreadInboxNotificationsByUserID, userID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const getFilteredInboxNotificationsByUserID = `-- name: GetFilteredInboxNotificationsByUserID :many
|
||||
SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE
|
||||
user_id = $1 AND
|
||||
template_id = ANY($2::UUID[]) AND
|
||||
targets @> COALESCE($3, ARRAY[]::UUID[]) AND
|
||||
($4::inbox_notification_read_status = 'all' OR ($4::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR ($4::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND
|
||||
($5::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < $5::TIMESTAMPTZ)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT (COALESCE(NULLIF($6 :: INT, 0), 25))
|
||||
`
|
||||
|
||||
type GetFilteredInboxNotificationsByUserIDParams struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Templates []uuid.UUID `db:"templates" json:"templates"`
|
||||
Targets []uuid.UUID `db:"targets" json:"targets"`
|
||||
ReadStatus InboxNotificationReadStatus `db:"read_status" json:"read_status"`
|
||||
CreatedAtOpt time.Time `db:"created_at_opt" json:"created_at_opt"`
|
||||
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
|
||||
}
|
||||
|
||||
// Fetches inbox notifications for a user filtered by templates and targets
|
||||
// param user_id: The user ID
|
||||
// param templates: The template IDs to filter by - the template_id = ANY(@templates::UUID[]) condition checks if the template_id is in the @templates array
|
||||
// param targets: The target IDs to filter by - the targets @> COALESCE(@targets, ARRAY[]::UUID[]) condition checks if the targets array (from the DB) contains all the elements in the @targets array
|
||||
// param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
|
||||
// param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
|
||||
// param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
|
||||
func (q *sqlQuerier) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg GetFilteredInboxNotificationsByUserIDParams) ([]InboxNotification, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getFilteredInboxNotificationsByUserID,
|
||||
arg.UserID,
|
||||
pq.Array(arg.Templates),
|
||||
pq.Array(arg.Targets),
|
||||
arg.ReadStatus,
|
||||
arg.CreatedAtOpt,
|
||||
arg.LimitOpt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []InboxNotification
|
||||
for rows.Next() {
|
||||
var i InboxNotification
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.TemplateID,
|
||||
pq.Array(&i.Targets),
|
||||
&i.Title,
|
||||
&i.Content,
|
||||
&i.Icon,
|
||||
&i.Actions,
|
||||
&i.ReadAt,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getInboxNotificationByID = `-- name: GetInboxNotificationByID :one
|
||||
SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (InboxNotification, error) {
|
||||
row := q.db.QueryRowContext(ctx, getInboxNotificationByID, id)
|
||||
var i InboxNotification
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.TemplateID,
|
||||
pq.Array(&i.Targets),
|
||||
&i.Title,
|
||||
&i.Content,
|
||||
&i.Icon,
|
||||
&i.Actions,
|
||||
&i.ReadAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getInboxNotificationsByUserID = `-- name: GetInboxNotificationsByUserID :many
|
||||
SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE
|
||||
user_id = $1 AND
|
||||
($2::inbox_notification_read_status = 'all' OR ($2::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR ($2::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND
|
||||
($3::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < $3::TIMESTAMPTZ)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT (COALESCE(NULLIF($4 :: INT, 0), 25))
|
||||
`
|
||||
|
||||
type GetInboxNotificationsByUserIDParams struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
ReadStatus InboxNotificationReadStatus `db:"read_status" json:"read_status"`
|
||||
CreatedAtOpt time.Time `db:"created_at_opt" json:"created_at_opt"`
|
||||
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
|
||||
}
|
||||
|
||||
// Fetches inbox notifications for a user filtered by templates and targets
|
||||
// param user_id: The user ID
|
||||
// param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
|
||||
// param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
|
||||
// param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
|
||||
func (q *sqlQuerier) GetInboxNotificationsByUserID(ctx context.Context, arg GetInboxNotificationsByUserIDParams) ([]InboxNotification, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getInboxNotificationsByUserID,
|
||||
arg.UserID,
|
||||
arg.ReadStatus,
|
||||
arg.CreatedAtOpt,
|
||||
arg.LimitOpt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []InboxNotification
|
||||
for rows.Next() {
|
||||
var i InboxNotification
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.TemplateID,
|
||||
pq.Array(&i.Targets),
|
||||
&i.Title,
|
||||
&i.Content,
|
||||
&i.Icon,
|
||||
&i.Actions,
|
||||
&i.ReadAt,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const insertInboxNotification = `-- name: InsertInboxNotification :one
|
||||
INSERT INTO
|
||||
inbox_notifications (
|
||||
id,
|
||||
user_id,
|
||||
template_id,
|
||||
targets,
|
||||
title,
|
||||
content,
|
||||
icon,
|
||||
actions,
|
||||
created_at
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at
|
||||
`
|
||||
|
||||
type InsertInboxNotificationParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
|
||||
Targets []uuid.UUID `db:"targets" json:"targets"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Content string `db:"content" json:"content"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
Actions json.RawMessage `db:"actions" json:"actions"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertInboxNotification(ctx context.Context, arg InsertInboxNotificationParams) (InboxNotification, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertInboxNotification,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.TemplateID,
|
||||
pq.Array(arg.Targets),
|
||||
arg.Title,
|
||||
arg.Content,
|
||||
arg.Icon,
|
||||
arg.Actions,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
var i InboxNotification
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.TemplateID,
|
||||
pq.Array(&i.Targets),
|
||||
&i.Title,
|
||||
&i.Content,
|
||||
&i.Icon,
|
||||
&i.Actions,
|
||||
&i.ReadAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateInboxNotificationReadStatus = `-- name: UpdateInboxNotificationReadStatus :exec
|
||||
UPDATE
|
||||
inbox_notifications
|
||||
SET
|
||||
read_at = $1
|
||||
WHERE
|
||||
id = $2
|
||||
`
|
||||
|
||||
type UpdateInboxNotificationReadStatusParams struct {
|
||||
ReadAt sql.NullTime `db:"read_at" json:"read_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateInboxNotificationReadStatus(ctx context.Context, arg UpdateInboxNotificationReadStatusParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateInboxNotificationReadStatus, arg.ReadAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOAuth2ProviderAppByID = `-- name: DeleteOAuth2ProviderAppByID :exec
|
||||
DELETE FROM oauth2_provider_apps WHERE id = $1
|
||||
`
|
||||
@ -6170,7 +6407,7 @@ WHERE
|
||||
AND (COALESCE(array_length($3::uuid[], 1), 0) = 0 OR pd.id = ANY($3::uuid[]))
|
||||
AND ($4::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, $4::tagset))
|
||||
ORDER BY
|
||||
pd.created_at ASC
|
||||
pd.created_at DESC
|
||||
LIMIT
|
||||
$5::int
|
||||
`
|
||||
@ -6715,45 +6952,69 @@ func (q *sqlQuerier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUI
|
||||
}
|
||||
|
||||
const getProvisionerJobsByIDsWithQueuePosition = `-- name: GetProvisionerJobsByIDsWithQueuePosition :many
|
||||
WITH pending_jobs AS (
|
||||
SELECT
|
||||
id, created_at
|
||||
FROM
|
||||
provisioner_jobs
|
||||
WHERE
|
||||
started_at IS NULL
|
||||
AND
|
||||
canceled_at IS NULL
|
||||
AND
|
||||
completed_at IS NULL
|
||||
AND
|
||||
error IS NULL
|
||||
WITH filtered_provisioner_jobs AS (
|
||||
-- Step 1: Filter provisioner_jobs
|
||||
SELECT
|
||||
id, created_at
|
||||
FROM
|
||||
provisioner_jobs
|
||||
WHERE
|
||||
id = ANY($1 :: uuid [ ]) -- Apply filter early to reduce dataset size before expensive JOIN
|
||||
),
|
||||
queue_position AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (ORDER BY created_at ASC) AS queue_position
|
||||
FROM
|
||||
pending_jobs
|
||||
pending_jobs AS (
|
||||
-- Step 2: Extract only pending jobs
|
||||
SELECT
|
||||
id, created_at, tags
|
||||
FROM
|
||||
provisioner_jobs
|
||||
WHERE
|
||||
job_status = 'pending'
|
||||
),
|
||||
queue_size AS (
|
||||
SELECT COUNT(*) AS count FROM pending_jobs
|
||||
ranked_jobs AS (
|
||||
-- Step 3: Rank only pending jobs based on provisioner availability
|
||||
SELECT
|
||||
pj.id,
|
||||
pj.created_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY pd.id ORDER BY pj.created_at ASC) AS queue_position,
|
||||
COUNT(*) OVER (PARTITION BY pd.id) AS queue_size
|
||||
FROM
|
||||
pending_jobs pj
|
||||
INNER JOIN provisioner_daemons pd
|
||||
ON provisioner_tagset_contains(pd.tags, pj.tags) -- Join only on the small pending set
|
||||
),
|
||||
final_jobs AS (
|
||||
-- Step 4: Compute best queue position and max queue size per job
|
||||
SELECT
|
||||
fpj.id,
|
||||
fpj.created_at,
|
||||
COALESCE(MIN(rj.queue_position), 0) :: BIGINT AS queue_position, -- Best queue position across provisioners
|
||||
COALESCE(MAX(rj.queue_size), 0) :: BIGINT AS queue_size -- Max queue size across provisioners
|
||||
FROM
|
||||
filtered_provisioner_jobs fpj -- Use the pre-filtered dataset instead of full provisioner_jobs
|
||||
LEFT JOIN ranked_jobs rj
|
||||
ON fpj.id = rj.id -- Join with the ranking jobs CTE to assign a rank to each specified provisioner job.
|
||||
GROUP BY
|
||||
fpj.id, fpj.created_at
|
||||
)
|
||||
SELECT
|
||||
-- Step 5: Final SELECT with INNER JOIN provisioner_jobs
|
||||
fj.id,
|
||||
fj.created_at,
|
||||
pj.id, pj.created_at, pj.updated_at, pj.started_at, pj.canceled_at, pj.completed_at, pj.error, pj.organization_id, pj.initiator_id, pj.provisioner, pj.storage_method, pj.type, pj.input, pj.worker_id, pj.file_id, pj.tags, pj.error_code, pj.trace_metadata, pj.job_status,
|
||||
COALESCE(qp.queue_position, 0) AS queue_position,
|
||||
COALESCE(qs.count, 0) AS queue_size
|
||||
fj.queue_position,
|
||||
fj.queue_size
|
||||
FROM
|
||||
provisioner_jobs pj
|
||||
LEFT JOIN
|
||||
queue_position qp ON qp.id = pj.id
|
||||
LEFT JOIN
|
||||
queue_size qs ON TRUE
|
||||
WHERE
|
||||
pj.id = ANY($1 :: uuid [ ])
|
||||
final_jobs fj
|
||||
INNER JOIN provisioner_jobs pj
|
||||
ON fj.id = pj.id -- Ensure we retrieve full details from ` + "`" + `provisioner_jobs` + "`" + `.
|
||||
-- JOIN with pj is required for sqlc.embed(pj) to compile successfully.
|
||||
ORDER BY
|
||||
fj.created_at
|
||||
`
|
||||
|
||||
type GetProvisionerJobsByIDsWithQueuePositionRow struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
ProvisionerJob ProvisionerJob `db:"provisioner_job" json:"provisioner_job"`
|
||||
QueuePosition int64 `db:"queue_position" json:"queue_position"`
|
||||
QueueSize int64 `db:"queue_size" json:"queue_size"`
|
||||
@ -6769,6 +7030,8 @@ func (q *sqlQuerier) GetProvisionerJobsByIDsWithQueuePosition(ctx context.Contex
|
||||
for rows.Next() {
|
||||
var i GetProvisionerJobsByIDsWithQueuePositionRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.CreatedAt,
|
||||
&i.ProvisionerJob.ID,
|
||||
&i.ProvisionerJob.CreatedAt,
|
||||
&i.ProvisionerJob.UpdatedAt,
|
||||
@ -8100,25 +8363,25 @@ SELECT
|
||||
FROM
|
||||
custom_roles
|
||||
WHERE
|
||||
true
|
||||
-- @lookup_roles will filter for exact (role_name, org_id) pairs
|
||||
-- To do this manually in SQL, you can construct an array and cast it:
|
||||
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
|
||||
AND CASE WHEN array_length($1 :: name_organization_pair[], 1) > 0 THEN
|
||||
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
|
||||
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY ($1::name_organization_pair[])
|
||||
ELSE true
|
||||
END
|
||||
-- This allows fetching all roles, or just site wide roles
|
||||
AND CASE WHEN $2 :: boolean THEN
|
||||
organization_id IS null
|
||||
true
|
||||
-- @lookup_roles will filter for exact (role_name, org_id) pairs
|
||||
-- To do this manually in SQL, you can construct an array and cast it:
|
||||
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
|
||||
AND CASE WHEN array_length($1 :: name_organization_pair[], 1) > 0 THEN
|
||||
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
|
||||
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY ($1::name_organization_pair[])
|
||||
ELSE true
|
||||
END
|
||||
-- Allows fetching all roles to a particular organization
|
||||
AND CASE WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
||||
organization_id = $3
|
||||
ELSE true
|
||||
END
|
||||
END
|
||||
-- This allows fetching all roles, or just site wide roles
|
||||
AND CASE WHEN $2 :: boolean THEN
|
||||
organization_id IS null
|
||||
ELSE true
|
||||
END
|
||||
-- Allows fetching all roles to a particular organization
|
||||
AND CASE WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
||||
organization_id = $3
|
||||
ELSE true
|
||||
END
|
||||
`
|
||||
|
||||
type CustomRolesParams struct {
|
||||
@ -8191,16 +8454,16 @@ INSERT INTO
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
-- Always force lowercase names
|
||||
lower($1),
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
-- Always force lowercase names
|
||||
lower($1),
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id
|
||||
`
|
||||
|
||||
@ -12478,6 +12741,46 @@ func (q *sqlQuerier) FetchMemoryResourceMonitorsByAgentID(ctx context.Context, a
|
||||
return i, err
|
||||
}
|
||||
|
||||
const fetchMemoryResourceMonitorsUpdatedAfter = `-- name: FetchMemoryResourceMonitorsUpdatedAfter :many
|
||||
SELECT
|
||||
agent_id, enabled, threshold, created_at, updated_at, state, debounced_until
|
||||
FROM
|
||||
workspace_agent_memory_resource_monitors
|
||||
WHERE
|
||||
updated_at > $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) FetchMemoryResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentMemoryResourceMonitor, error) {
|
||||
rows, err := q.db.QueryContext(ctx, fetchMemoryResourceMonitorsUpdatedAfter, updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []WorkspaceAgentMemoryResourceMonitor
|
||||
for rows.Next() {
|
||||
var i WorkspaceAgentMemoryResourceMonitor
|
||||
if err := rows.Scan(
|
||||
&i.AgentID,
|
||||
&i.Enabled,
|
||||
&i.Threshold,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.State,
|
||||
&i.DebouncedUntil,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const fetchVolumesResourceMonitorsByAgentID = `-- name: FetchVolumesResourceMonitorsByAgentID :many
|
||||
SELECT
|
||||
agent_id, enabled, threshold, path, created_at, updated_at, state, debounced_until
|
||||
@ -12519,6 +12822,47 @@ func (q *sqlQuerier) FetchVolumesResourceMonitorsByAgentID(ctx context.Context,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const fetchVolumesResourceMonitorsUpdatedAfter = `-- name: FetchVolumesResourceMonitorsUpdatedAfter :many
|
||||
SELECT
|
||||
agent_id, enabled, threshold, path, created_at, updated_at, state, debounced_until
|
||||
FROM
|
||||
workspace_agent_volume_resource_monitors
|
||||
WHERE
|
||||
updated_at > $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
rows, err := q.db.QueryContext(ctx, fetchVolumesResourceMonitorsUpdatedAfter, updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []WorkspaceAgentVolumeResourceMonitor
|
||||
for rows.Next() {
|
||||
var i WorkspaceAgentVolumeResourceMonitor
|
||||
if err := rows.Scan(
|
||||
&i.AgentID,
|
||||
&i.Enabled,
|
||||
&i.Threshold,
|
||||
&i.Path,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.State,
|
||||
&i.DebouncedUntil,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const insertMemoryResourceMonitor = `-- name: InsertMemoryResourceMonitor :one
|
||||
INSERT INTO
|
||||
workspace_agent_memory_resource_monitors (
|
||||
@ -16596,13 +16940,11 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace
|
||||
}
|
||||
|
||||
const getWorkspaceUniqueOwnerCountByTemplateIDs = `-- name: GetWorkspaceUniqueOwnerCountByTemplateIDs :many
|
||||
SELECT
|
||||
template_id, COUNT(DISTINCT owner_id) AS unique_owners_sum
|
||||
FROM
|
||||
workspaces
|
||||
WHERE
|
||||
template_id = ANY($1 :: uuid[]) AND deleted = false
|
||||
GROUP BY template_id
|
||||
SELECT templates.id AS template_id, COUNT(DISTINCT workspaces.owner_id) AS unique_owners_sum
|
||||
FROM templates
|
||||
LEFT JOIN workspaces ON workspaces.template_id = templates.id AND workspaces.deleted = false
|
||||
WHERE templates.id = ANY($1 :: uuid[])
|
||||
GROUP BY templates.id
|
||||
`
|
||||
|
||||
type GetWorkspaceUniqueOwnerCountByTemplateIDsRow struct {
|
||||
|
59
coderd/database/queries/notificationsinbox.sql
Normal file
59
coderd/database/queries/notificationsinbox.sql
Normal file
@ -0,0 +1,59 @@
|
||||
-- name: GetInboxNotificationsByUserID :many
|
||||
-- Fetches inbox notifications for a user filtered by templates and targets
|
||||
-- param user_id: The user ID
|
||||
-- param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
|
||||
-- param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
|
||||
-- param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
|
||||
SELECT * FROM inbox_notifications WHERE
|
||||
user_id = @user_id AND
|
||||
(@read_status::inbox_notification_read_status = 'all' OR (@read_status::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR (@read_status::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND
|
||||
(@created_at_opt::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < @created_at_opt::TIMESTAMPTZ)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT (COALESCE(NULLIF(@limit_opt :: INT, 0), 25));
|
||||
|
||||
-- name: GetFilteredInboxNotificationsByUserID :many
|
||||
-- Fetches inbox notifications for a user filtered by templates and targets
|
||||
-- param user_id: The user ID
|
||||
-- param templates: The template IDs to filter by - the template_id = ANY(@templates::UUID[]) condition checks if the template_id is in the @templates array
|
||||
-- param targets: The target IDs to filter by - the targets @> COALESCE(@targets, ARRAY[]::UUID[]) condition checks if the targets array (from the DB) contains all the elements in the @targets array
|
||||
-- param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ'
|
||||
-- param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value
|
||||
-- param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25
|
||||
SELECT * FROM inbox_notifications WHERE
|
||||
user_id = @user_id AND
|
||||
template_id = ANY(@templates::UUID[]) AND
|
||||
targets @> COALESCE(@targets, ARRAY[]::UUID[]) AND
|
||||
(@read_status::inbox_notification_read_status = 'all' OR (@read_status::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR (@read_status::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND
|
||||
(@created_at_opt::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < @created_at_opt::TIMESTAMPTZ)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT (COALESCE(NULLIF(@limit_opt :: INT, 0), 25));
|
||||
|
||||
-- name: GetInboxNotificationByID :one
|
||||
SELECT * FROM inbox_notifications WHERE id = $1;
|
||||
|
||||
-- name: CountUnreadInboxNotificationsByUserID :one
|
||||
SELECT COUNT(*) FROM inbox_notifications WHERE user_id = $1 AND read_at IS NULL;
|
||||
|
||||
-- name: InsertInboxNotification :one
|
||||
INSERT INTO
|
||||
inbox_notifications (
|
||||
id,
|
||||
user_id,
|
||||
template_id,
|
||||
targets,
|
||||
title,
|
||||
content,
|
||||
icon,
|
||||
actions,
|
||||
created_at
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *;
|
||||
|
||||
-- name: UpdateInboxNotificationReadStatus :exec
|
||||
UPDATE
|
||||
inbox_notifications
|
||||
SET
|
||||
read_at = $1
|
||||
WHERE
|
||||
id = $2;
|
@ -111,7 +111,7 @@ WHERE
|
||||
AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pd.id = ANY(@ids::uuid[]))
|
||||
AND (@tags::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, @tags::tagset))
|
||||
ORDER BY
|
||||
pd.created_at ASC
|
||||
pd.created_at DESC
|
||||
LIMIT
|
||||
sqlc.narg('limit')::int;
|
||||
|
||||
|
@ -50,42 +50,64 @@ WHERE
|
||||
id = ANY(@ids :: uuid [ ]);
|
||||
|
||||
-- name: GetProvisionerJobsByIDsWithQueuePosition :many
|
||||
WITH pending_jobs AS (
|
||||
SELECT
|
||||
id, created_at
|
||||
FROM
|
||||
provisioner_jobs
|
||||
WHERE
|
||||
started_at IS NULL
|
||||
AND
|
||||
canceled_at IS NULL
|
||||
AND
|
||||
completed_at IS NULL
|
||||
AND
|
||||
error IS NULL
|
||||
WITH filtered_provisioner_jobs AS (
|
||||
-- Step 1: Filter provisioner_jobs
|
||||
SELECT
|
||||
id, created_at
|
||||
FROM
|
||||
provisioner_jobs
|
||||
WHERE
|
||||
id = ANY(@ids :: uuid [ ]) -- Apply filter early to reduce dataset size before expensive JOIN
|
||||
),
|
||||
queue_position AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (ORDER BY created_at ASC) AS queue_position
|
||||
FROM
|
||||
pending_jobs
|
||||
pending_jobs AS (
|
||||
-- Step 2: Extract only pending jobs
|
||||
SELECT
|
||||
id, created_at, tags
|
||||
FROM
|
||||
provisioner_jobs
|
||||
WHERE
|
||||
job_status = 'pending'
|
||||
),
|
||||
queue_size AS (
|
||||
SELECT COUNT(*) AS count FROM pending_jobs
|
||||
ranked_jobs AS (
|
||||
-- Step 3: Rank only pending jobs based on provisioner availability
|
||||
SELECT
|
||||
pj.id,
|
||||
pj.created_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY pd.id ORDER BY pj.created_at ASC) AS queue_position,
|
||||
COUNT(*) OVER (PARTITION BY pd.id) AS queue_size
|
||||
FROM
|
||||
pending_jobs pj
|
||||
INNER JOIN provisioner_daemons pd
|
||||
ON provisioner_tagset_contains(pd.tags, pj.tags) -- Join only on the small pending set
|
||||
),
|
||||
final_jobs AS (
|
||||
-- Step 4: Compute best queue position and max queue size per job
|
||||
SELECT
|
||||
fpj.id,
|
||||
fpj.created_at,
|
||||
COALESCE(MIN(rj.queue_position), 0) :: BIGINT AS queue_position, -- Best queue position across provisioners
|
||||
COALESCE(MAX(rj.queue_size), 0) :: BIGINT AS queue_size -- Max queue size across provisioners
|
||||
FROM
|
||||
filtered_provisioner_jobs fpj -- Use the pre-filtered dataset instead of full provisioner_jobs
|
||||
LEFT JOIN ranked_jobs rj
|
||||
ON fpj.id = rj.id -- Join with the ranking jobs CTE to assign a rank to each specified provisioner job.
|
||||
GROUP BY
|
||||
fpj.id, fpj.created_at
|
||||
)
|
||||
SELECT
|
||||
-- Step 5: Final SELECT with INNER JOIN provisioner_jobs
|
||||
fj.id,
|
||||
fj.created_at,
|
||||
sqlc.embed(pj),
|
||||
COALESCE(qp.queue_position, 0) AS queue_position,
|
||||
COALESCE(qs.count, 0) AS queue_size
|
||||
fj.queue_position,
|
||||
fj.queue_size
|
||||
FROM
|
||||
provisioner_jobs pj
|
||||
LEFT JOIN
|
||||
queue_position qp ON qp.id = pj.id
|
||||
LEFT JOIN
|
||||
queue_size qs ON TRUE
|
||||
WHERE
|
||||
pj.id = ANY(@ids :: uuid [ ]);
|
||||
final_jobs fj
|
||||
INNER JOIN provisioner_jobs pj
|
||||
ON fj.id = pj.id -- Ensure we retrieve full details from `provisioner_jobs`.
|
||||
-- JOIN with pj is required for sqlc.embed(pj) to compile successfully.
|
||||
ORDER BY
|
||||
fj.created_at;
|
||||
|
||||
-- name: GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner :many
|
||||
WITH pending_jobs AS (
|
||||
|
@ -4,25 +4,25 @@ SELECT
|
||||
FROM
|
||||
custom_roles
|
||||
WHERE
|
||||
true
|
||||
-- @lookup_roles will filter for exact (role_name, org_id) pairs
|
||||
-- To do this manually in SQL, you can construct an array and cast it:
|
||||
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
|
||||
AND CASE WHEN array_length(@lookup_roles :: name_organization_pair[], 1) > 0 THEN
|
||||
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
|
||||
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY (@lookup_roles::name_organization_pair[])
|
||||
ELSE true
|
||||
END
|
||||
-- This allows fetching all roles, or just site wide roles
|
||||
AND CASE WHEN @exclude_org_roles :: boolean THEN
|
||||
organization_id IS null
|
||||
true
|
||||
-- @lookup_roles will filter for exact (role_name, org_id) pairs
|
||||
-- To do this manually in SQL, you can construct an array and cast it:
|
||||
-- cast(ARRAY[('customrole','ece79dac-926e-44ca-9790-2ff7c5eb6e0c')] AS name_organization_pair[])
|
||||
AND CASE WHEN array_length(@lookup_roles :: name_organization_pair[], 1) > 0 THEN
|
||||
-- Using 'coalesce' to avoid troubles with null literals being an empty string.
|
||||
(name, coalesce(organization_id, '00000000-0000-0000-0000-000000000000' ::uuid)) = ANY (@lookup_roles::name_organization_pair[])
|
||||
ELSE true
|
||||
END
|
||||
-- Allows fetching all roles to a particular organization
|
||||
AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
||||
organization_id = @organization_id
|
||||
ELSE true
|
||||
END
|
||||
END
|
||||
-- This allows fetching all roles, or just site wide roles
|
||||
AND CASE WHEN @exclude_org_roles :: boolean THEN
|
||||
organization_id IS null
|
||||
ELSE true
|
||||
END
|
||||
-- Allows fetching all roles to a particular organization
|
||||
AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
||||
organization_id = @organization_id
|
||||
ELSE true
|
||||
END
|
||||
;
|
||||
|
||||
-- name: DeleteCustomRole :exec
|
||||
@ -46,16 +46,16 @@ INSERT INTO
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
-- Always force lowercase names
|
||||
lower(@name),
|
||||
@display_name,
|
||||
@organization_id,
|
||||
@site_permissions,
|
||||
@org_permissions,
|
||||
@user_permissions,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
-- Always force lowercase names
|
||||
lower(@name),
|
||||
@display_name,
|
||||
@organization_id,
|
||||
@site_permissions,
|
||||
@org_permissions,
|
||||
@user_permissions,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateCustomRole :one
|
||||
|
@ -1,3 +1,19 @@
|
||||
-- name: FetchVolumesResourceMonitorsUpdatedAfter :many
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
workspace_agent_volume_resource_monitors
|
||||
WHERE
|
||||
updated_at > $1;
|
||||
|
||||
-- name: FetchMemoryResourceMonitorsUpdatedAfter :many
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
workspace_agent_memory_resource_monitors
|
||||
WHERE
|
||||
updated_at > $1;
|
||||
|
||||
-- name: FetchMemoryResourceMonitorsByAgentID :one
|
||||
SELECT
|
||||
*
|
||||
|
@ -415,13 +415,11 @@ WHERE
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetWorkspaceUniqueOwnerCountByTemplateIDs :many
|
||||
SELECT
|
||||
template_id, COUNT(DISTINCT owner_id) AS unique_owners_sum
|
||||
FROM
|
||||
workspaces
|
||||
WHERE
|
||||
template_id = ANY(@template_ids :: uuid[]) AND deleted = false
|
||||
GROUP BY template_id;
|
||||
SELECT templates.id AS template_id, COUNT(DISTINCT workspaces.owner_id) AS unique_owners_sum
|
||||
FROM templates
|
||||
LEFT JOIN workspaces ON workspaces.template_id = templates.id AND workspaces.deleted = false
|
||||
WHERE templates.id = ANY(@template_ids :: uuid[])
|
||||
GROUP BY templates.id;
|
||||
|
||||
-- name: InsertWorkspace :one
|
||||
INSERT INTO
|
||||
|
@ -21,6 +21,7 @@ const (
|
||||
UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id);
|
||||
UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id);
|
||||
UniqueGroupsPkey UniqueConstraint = "groups_pkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_pkey PRIMARY KEY (id);
|
||||
UniqueInboxNotificationsPkey UniqueConstraint = "inbox_notifications_pkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_pkey PRIMARY KEY (id);
|
||||
UniqueJfrogXrayScansPkey UniqueConstraint = "jfrog_xray_scans_pkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_pkey PRIMARY KEY (agent_id, workspace_id);
|
||||
UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt);
|
||||
UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id);
|
||||
|
Reference in New Issue
Block a user