mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add personal skill storage, API, and SDK (#25363)
> Mux updated this PR on behalf of Mike. ## Stack Context This PR is the storage, permissions, API, and SDK layer for experimental personal skills. #25362 has landed on `main`, so this branch is restacked directly on `main`. Stack order: 1. #25363 storage, permissions, API, and SDK 2. #25365 API test coverage 3. #25366 chattool and chatd integration 4. #25066 settings UI and docs 5. #25386 personal skills slash menu ## What? Adds the `user_skills` database table, generated queries, RBAC resources and scopes, audit resource handling, experimental user-scoped CRUD endpoints, SDK types, and generated API/site types. Follow-up review and restack fixes: - Enforce a bounded personal skill description in parser and database constraints. - Return `403 Forbidden` for unauthorized create and update attempts. - Return explicit conflict responses when soft-deleted users are targeted. - Keep user admins out of personal skills, while site owners can read and delete but not create or update. - Document trigger-raised constraint names and keep schema constants covered by tests. - Reuse `UserSkillMetadata` in the full `UserSkill` SDK response type. - Generate user skill IDs in Go instead of relying on a database default. - Rebase on latest `main` and renumber the user skills migration to `000502_user_skills`. ## Why? Personal skills need durable user-owned storage with owner authorization, limited site-owner moderation, and a hidden API surface before chatd can consume them. ## Validation - `make gen` - `go test ./coderd/database -run '^TestUserSkillSchemaConstants$' -count=1` - `go test ./coderd/database/dbauthz -run '^TestMethodTestSuite/TestUserSkills$' -count=1` - `go test ./coderd -run '^TestPatchUserSkill$' -count=1` - `go test ./codersdk ./coderd/database/db2sdk` - `make lint` - pre-commit hook on `97fd58108d`
This commit is contained in:
@@ -45,4 +45,8 @@ const (
|
||||
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
|
||||
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
|
||||
CheckUserChatProviderKeysAPIKeyCheck CheckConstraint = "user_chat_provider_keys_api_key_check" // user_chat_provider_keys
|
||||
CheckUserSkillsContentSize CheckConstraint = "user_skills_content_size" // user_skills
|
||||
CheckUserSkillsDescriptionSize CheckConstraint = "user_skills_description_size" // user_skills
|
||||
CheckUserSkillsNameFormat CheckConstraint = "user_skills_name_format" // user_skills
|
||||
CheckUserSkillsNameSize CheckConstraint = "user_skills_name_size" // user_skills
|
||||
)
|
||||
|
||||
@@ -2046,3 +2046,37 @@ func UserSecrets(secrets []database.ListUserSecretsRow) []codersdk.UserSecret {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UserSkill converts a database UserSkill to an SDK UserSkill.
|
||||
func UserSkill(skill database.UserSkill) codersdk.UserSkill {
|
||||
return codersdk.UserSkill{
|
||||
UserSkillMetadata: codersdk.UserSkillMetadata{
|
||||
ID: skill.ID,
|
||||
Name: skill.Name,
|
||||
Description: skill.Description,
|
||||
CreatedAt: skill.CreatedAt,
|
||||
UpdatedAt: skill.UpdatedAt,
|
||||
},
|
||||
Content: skill.Content,
|
||||
}
|
||||
}
|
||||
|
||||
// UserSkillMetadata converts database user skill metadata to an SDK UserSkillMetadata.
|
||||
func UserSkillMetadata(skill database.ListUserSkillMetadataByUserIDRow) codersdk.UserSkillMetadata {
|
||||
return codersdk.UserSkillMetadata{
|
||||
ID: skill.ID,
|
||||
Name: skill.Name,
|
||||
Description: skill.Description,
|
||||
CreatedAt: skill.CreatedAt,
|
||||
UpdatedAt: skill.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// UserSkillMetadataList converts database user skill metadata rows to SDK values.
|
||||
func UserSkillMetadataList(rows []database.ListUserSkillMetadataByUserIDRow) []codersdk.UserSkillMetadata {
|
||||
metadata := make([]codersdk.UserSkillMetadata, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
metadata = append(metadata, UserSkillMetadata(row))
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
@@ -2290,6 +2290,14 @@ func (q *querier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg datab
|
||||
return q.db.DeleteUserSecretByUserIDAndName(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
|
||||
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, obj); err != nil {
|
||||
return database.UserSkill{}, err
|
||||
}
|
||||
return q.db.DeleteUserSkillByUserIDAndName(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceWebpushSubscription.WithOwner(arg.UserID.String())); err != nil {
|
||||
return err
|
||||
@@ -4687,6 +4695,14 @@ func (q *querier) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.U
|
||||
return q.db.GetUserShellToolDisplayMode(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
|
||||
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil {
|
||||
return database.UserSkill{}, err
|
||||
}
|
||||
return q.db.GetUserSkillByUserIDAndName(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
|
||||
return nil, err
|
||||
@@ -5780,6 +5796,14 @@ func (q *querier) InsertUserLink(ctx context.Context, arg database.InsertUserLin
|
||||
return q.db.InsertUserLink(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.UserSkill, error) {
|
||||
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, obj); err != nil {
|
||||
return database.UserSkill{}, err
|
||||
}
|
||||
return q.db.InsertUserSkill(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertVolumeResourceMonitor(ctx context.Context, arg database.InsertVolumeResourceMonitorParams) (database.WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceWorkspaceAgentResourceMonitor); err != nil {
|
||||
return database.WorkspaceAgentVolumeResourceMonitor{}, err
|
||||
@@ -6139,6 +6163,14 @@ func (q *querier) ListUserSecretsWithValues(ctx context.Context, userID uuid.UUI
|
||||
return q.db.ListUserSecretsWithValues(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]database.ListUserSkillMetadataByUserIDRow, error) {
|
||||
obj := rbac.ResourceUserSkill.WithOwner(userID.String())
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.ListUserSkillMetadataByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
|
||||
workspace, err := q.db.GetWorkspaceByID(ctx, workspaceID)
|
||||
if err != nil {
|
||||
@@ -7446,6 +7478,14 @@ func (q *querier) UpdateUserShellToolDisplayMode(ctx context.Context, arg databa
|
||||
return q.db.UpdateUserShellToolDisplayMode(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
|
||||
obj := rbac.ResourceUserSkill.WithOwner(arg.UserID.String())
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil {
|
||||
return database.UserSkill{}, err
|
||||
}
|
||||
return q.db.UpdateUserSkillByUserIDAndName(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
||||
fetch := func(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
||||
return q.db.GetUserByID(ctx, arg.ID)
|
||||
|
||||
@@ -6046,6 +6046,61 @@ func (s *MethodTestSuite) TestUserSecrets() {
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *MethodTestSuite) TestUserSkills() {
|
||||
s.Run("GetUserSkillByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
skill := testutil.Fake(s.T(), faker, database.UserSkill{UserID: user.ID})
|
||||
arg := database.GetUserSkillByUserIDAndNameParams{UserID: user.ID, Name: skill.Name}
|
||||
dbm.EXPECT().GetUserSkillByUserIDAndName(gomock.Any(), arg).Return(skill, nil).AnyTimes()
|
||||
check.Args(arg).
|
||||
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionRead).
|
||||
Returns(skill)
|
||||
}))
|
||||
s.Run("ListUserSkillMetadataByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
row := testutil.Fake(s.T(), faker, database.ListUserSkillMetadataByUserIDRow{UserID: user.ID})
|
||||
dbm.EXPECT().ListUserSkillMetadataByUserID(gomock.Any(), user.ID).Return([]database.ListUserSkillMetadataByUserIDRow{row}, nil).AnyTimes()
|
||||
check.Args(user.ID).
|
||||
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionRead).
|
||||
Returns([]database.ListUserSkillMetadataByUserIDRow{row})
|
||||
}))
|
||||
s.Run("InsertUserSkill", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
arg := database.InsertUserSkillParams{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
Name: "test",
|
||||
}
|
||||
ret := testutil.Fake(s.T(), faker, database.UserSkill{
|
||||
ID: arg.ID,
|
||||
UserID: user.ID,
|
||||
Name: arg.Name,
|
||||
})
|
||||
dbm.EXPECT().InsertUserSkill(gomock.Any(), arg).Return(ret, nil).AnyTimes()
|
||||
check.Args(arg).
|
||||
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionCreate).
|
||||
Returns(ret)
|
||||
}))
|
||||
s.Run("UpdateUserSkillByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
arg := database.UpdateUserSkillByUserIDAndNameParams{UserID: user.ID, Name: "test"}
|
||||
updated := testutil.Fake(s.T(), faker, database.UserSkill{UserID: user.ID, Name: arg.Name})
|
||||
dbm.EXPECT().UpdateUserSkillByUserIDAndName(gomock.Any(), arg).Return(updated, nil).AnyTimes()
|
||||
check.Args(arg).
|
||||
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionUpdate).
|
||||
Returns(updated)
|
||||
}))
|
||||
s.Run("DeleteUserSkillByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
arg := database.DeleteUserSkillByUserIDAndNameParams{UserID: user.ID, Name: "test"}
|
||||
deleted := testutil.Fake(s.T(), faker, database.UserSkill{UserID: user.ID, Name: arg.Name})
|
||||
dbm.EXPECT().DeleteUserSkillByUserIDAndName(gomock.Any(), arg).Return(deleted, nil).AnyTimes()
|
||||
check.Args(arg).
|
||||
Asserts(rbac.ResourceUserSkill.WithOwner(user.ID.String()), policy.ActionDelete).
|
||||
Returns(deleted)
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *MethodTestSuite) TestUsageEvents() {
|
||||
s.Run("InsertUsageEvent", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
params := database.InsertUsageEventParams{
|
||||
|
||||
@@ -809,6 +809,14 @@ func (m queryMetricsStore) DeleteUserSecretByUserIDAndName(ctx context.Context,
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.DeleteUserSkillByUserIDAndName(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("DeleteUserSkillByUserIDAndName").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteUserSkillByUserIDAndName").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg)
|
||||
@@ -3073,6 +3081,14 @@ func (m queryMetricsStore) GetUserShellToolDisplayMode(ctx context.Context, user
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUserSkillByUserIDAndName(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetUserSkillByUserIDAndName").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserSkillByUserIDAndName").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUserStatusCounts(ctx, arg)
|
||||
@@ -4097,6 +4113,14 @@ func (m queryMetricsStore) InsertUserLink(ctx context.Context, arg database.Inse
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.UserSkill, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.InsertUserSkill(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("InsertUserSkill").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertUserSkill").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertVolumeResourceMonitor(ctx context.Context, arg database.InsertVolumeResourceMonitorParams) (database.WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.InsertVolumeResourceMonitor(ctx, arg)
|
||||
@@ -4409,6 +4433,14 @@ func (m queryMetricsStore) ListUserSecretsWithValues(ctx context.Context, userID
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]database.ListUserSkillMetadataByUserIDRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.ListUserSkillMetadataByUserID(ctx, userID)
|
||||
m.queryLatencies.WithLabelValues("ListUserSkillMetadataByUserID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListUserSkillMetadataByUserID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.ListWorkspaceAgentPortShares(ctx, workspaceID)
|
||||
@@ -5329,6 +5361,14 @@ func (m queryMetricsStore) UpdateUserShellToolDisplayMode(ctx context.Context, a
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateUserSkillByUserIDAndName(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateUserSkillByUserIDAndName").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserSkillByUserIDAndName").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateUserStatus(ctx, arg)
|
||||
|
||||
@@ -1377,6 +1377,21 @@ func (mr *MockStoreMockRecorder) DeleteUserSecretByUserIDAndName(ctx, arg any) *
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserSecretByUserIDAndName", reflect.TypeOf((*MockStore)(nil).DeleteUserSecretByUserIDAndName), ctx, arg)
|
||||
}
|
||||
|
||||
// DeleteUserSkillByUserIDAndName mocks base method.
|
||||
func (m *MockStore) DeleteUserSkillByUserIDAndName(ctx context.Context, arg database.DeleteUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteUserSkillByUserIDAndName", ctx, arg)
|
||||
ret0, _ := ret[0].(database.UserSkill)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DeleteUserSkillByUserIDAndName indicates an expected call of DeleteUserSkillByUserIDAndName.
|
||||
func (mr *MockStoreMockRecorder) DeleteUserSkillByUserIDAndName(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserSkillByUserIDAndName", reflect.TypeOf((*MockStore)(nil).DeleteUserSkillByUserIDAndName), ctx, arg)
|
||||
}
|
||||
|
||||
// DeleteWebpushSubscriptionByUserIDAndEndpoint mocks base method.
|
||||
func (m *MockStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -5761,6 +5776,21 @@ func (mr *MockStoreMockRecorder) GetUserShellToolDisplayMode(ctx, userID any) *g
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserShellToolDisplayMode", reflect.TypeOf((*MockStore)(nil).GetUserShellToolDisplayMode), ctx, userID)
|
||||
}
|
||||
|
||||
// GetUserSkillByUserIDAndName mocks base method.
|
||||
func (m *MockStore) GetUserSkillByUserIDAndName(ctx context.Context, arg database.GetUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUserSkillByUserIDAndName", ctx, arg)
|
||||
ret0, _ := ret[0].(database.UserSkill)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUserSkillByUserIDAndName indicates an expected call of GetUserSkillByUserIDAndName.
|
||||
func (mr *MockStoreMockRecorder) GetUserSkillByUserIDAndName(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSkillByUserIDAndName", reflect.TypeOf((*MockStore)(nil).GetUserSkillByUserIDAndName), ctx, arg)
|
||||
}
|
||||
|
||||
// GetUserStatusCounts mocks base method.
|
||||
func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -7685,6 +7715,21 @@ func (mr *MockStoreMockRecorder) InsertUserLink(ctx, arg any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertUserLink", reflect.TypeOf((*MockStore)(nil).InsertUserLink), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertUserSkill mocks base method.
|
||||
func (m *MockStore) InsertUserSkill(ctx context.Context, arg database.InsertUserSkillParams) (database.UserSkill, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "InsertUserSkill", ctx, arg)
|
||||
ret0, _ := ret[0].(database.UserSkill)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// InsertUserSkill indicates an expected call of InsertUserSkill.
|
||||
func (mr *MockStoreMockRecorder) InsertUserSkill(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertUserSkill", reflect.TypeOf((*MockStore)(nil).InsertUserSkill), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertVolumeResourceMonitor mocks base method.
|
||||
func (m *MockStore) InsertVolumeResourceMonitor(ctx context.Context, arg database.InsertVolumeResourceMonitorParams) (database.WorkspaceAgentVolumeResourceMonitor, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -8340,6 +8385,21 @@ func (mr *MockStoreMockRecorder) ListUserSecretsWithValues(ctx, userID any) *gom
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserSecretsWithValues", reflect.TypeOf((*MockStore)(nil).ListUserSecretsWithValues), ctx, userID)
|
||||
}
|
||||
|
||||
// ListUserSkillMetadataByUserID mocks base method.
|
||||
func (m *MockStore) ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]database.ListUserSkillMetadataByUserIDRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListUserSkillMetadataByUserID", ctx, userID)
|
||||
ret0, _ := ret[0].([]database.ListUserSkillMetadataByUserIDRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ListUserSkillMetadataByUserID indicates an expected call of ListUserSkillMetadataByUserID.
|
||||
func (mr *MockStoreMockRecorder) ListUserSkillMetadataByUserID(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserSkillMetadataByUserID", reflect.TypeOf((*MockStore)(nil).ListUserSkillMetadataByUserID), ctx, userID)
|
||||
}
|
||||
|
||||
// ListWorkspaceAgentPortShares mocks base method.
|
||||
func (m *MockStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -10048,6 +10108,21 @@ func (mr *MockStoreMockRecorder) UpdateUserShellToolDisplayMode(ctx, arg any) *g
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserShellToolDisplayMode", reflect.TypeOf((*MockStore)(nil).UpdateUserShellToolDisplayMode), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateUserSkillByUserIDAndName mocks base method.
|
||||
func (m *MockStore) UpdateUserSkillByUserIDAndName(ctx context.Context, arg database.UpdateUserSkillByUserIDAndNameParams) (database.UserSkill, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateUserSkillByUserIDAndName", ctx, arg)
|
||||
ret0, _ := ret[0].(database.UserSkill)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateUserSkillByUserIDAndName indicates an expected call of UpdateUserSkillByUserIDAndName.
|
||||
func (mr *MockStoreMockRecorder) UpdateUserSkillByUserIDAndName(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserSkillByUserIDAndName", reflect.TypeOf((*MockStore)(nil).UpdateUserSkillByUserIDAndName), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateUserStatus mocks base method.
|
||||
func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+107
-24
@@ -243,7 +243,12 @@ CREATE TYPE api_key_scope AS ENUM (
|
||||
'ai_provider:delete',
|
||||
'ai_provider:read',
|
||||
'ai_provider:update',
|
||||
'chat:share'
|
||||
'chat:share',
|
||||
'user_skill:create',
|
||||
'user_skill:read',
|
||||
'user_skill:update',
|
||||
'user_skill:delete',
|
||||
'user_skill:*'
|
||||
);
|
||||
|
||||
CREATE TYPE app_sharing_level AS ENUM (
|
||||
@@ -553,7 +558,8 @@ CREATE TYPE resource_type AS ENUM (
|
||||
'user_secret',
|
||||
'ai_provider',
|
||||
'ai_provider_key',
|
||||
'group_ai_budget'
|
||||
'group_ai_budget',
|
||||
'user_skill'
|
||||
);
|
||||
|
||||
CREATE TYPE shareable_workspace_owners AS ENUM (
|
||||
@@ -771,31 +777,37 @@ CREATE FUNCTION delete_deleted_user_resources() RETURNS trigger
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
IF (NEW.deleted) THEN
|
||||
-- Remove their api_keys
|
||||
DELETE FROM api_keys
|
||||
WHERE user_id = OLD.id;
|
||||
IF (NEW.deleted) THEN
|
||||
-- Remove their api_keys.
|
||||
DELETE FROM api_keys
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_links
|
||||
-- Their login_type is preserved in the users table.
|
||||
-- Matching this user back to the link can still be done by their
|
||||
-- email if the account is undeleted. Although that is not a guarantee.
|
||||
DELETE FROM user_links
|
||||
WHERE user_id = OLD.id;
|
||||
-- Remove their user_links.
|
||||
-- Their login_type is preserved in the users table.
|
||||
-- Matching this user back to the link can still be done by their
|
||||
-- email if the account is undeleted. Although that is not a guarantee.
|
||||
DELETE FROM user_links
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_secrets.
|
||||
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
|
||||
-- does not remove the users row so the FK cascade never fires.
|
||||
DELETE FROM user_secrets
|
||||
WHERE user_id = OLD.id;
|
||||
-- Remove their user_secrets.
|
||||
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
|
||||
-- does not remove the users row so the FK cascade never fires.
|
||||
DELETE FROM user_secrets
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their organization memberships.
|
||||
-- This also triggers group membership cleanup via
|
||||
-- trigger_delete_group_members_on_org_member_delete.
|
||||
DELETE FROM organization_members
|
||||
WHERE user_id = OLD.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
-- Remove their organization memberships.
|
||||
-- This also triggers group membership cleanup via
|
||||
-- trigger_delete_group_members_on_org_member_delete.
|
||||
DELETE FROM organization_members
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_skills.
|
||||
-- user_skills.user_id has ON DELETE CASCADE, but soft-delete
|
||||
-- does not remove the users row so the FK cascade never fires.
|
||||
DELETE FROM user_skills
|
||||
WHERE user_id = OLD.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
@@ -818,6 +830,32 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION enforce_user_skills_per_user_limit() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
skill_count int;
|
||||
skill_limit constant int := 100;
|
||||
BEGIN
|
||||
-- Serialize skill-cap checks per user so concurrent inserts cannot all
|
||||
-- observe the same pre-insert count and exceed the hard limit.
|
||||
PERFORM 1
|
||||
FROM users
|
||||
WHERE id = NEW.user_id
|
||||
FOR UPDATE;
|
||||
|
||||
SELECT count(*) INTO skill_count
|
||||
FROM user_skills
|
||||
WHERE user_id = NEW.user_id;
|
||||
IF skill_count >= skill_limit THEN
|
||||
RAISE EXCEPTION 'user has reached the personal skill limit'
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_skills_per_user_limit';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION inhibit_enqueue_if_disabled() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -935,6 +973,25 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION insert_user_skill_fail_if_user_deleted() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
|
||||
BEGIN
|
||||
PERFORM 1
|
||||
FROM users
|
||||
WHERE id = NEW.user_id
|
||||
AND deleted = true
|
||||
LIMIT 1;
|
||||
IF FOUND THEN
|
||||
RAISE EXCEPTION 'Cannot create user_skill for deleted user'
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_skill_user_deleted';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION nullify_next_start_at_on_workspace_autostart_modification() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -3015,6 +3072,20 @@ CREATE TABLE user_secrets (
|
||||
value_key_id text
|
||||
);
|
||||
|
||||
CREATE TABLE user_skills (
|
||||
id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text DEFAULT ''::text NOT NULL,
|
||||
content text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT user_skills_content_size CHECK ((octet_length(content) <= 65536)),
|
||||
CONSTRAINT user_skills_description_size CHECK ((octet_length(description) <= 4096)),
|
||||
CONSTRAINT user_skills_name_format CHECK ((name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text)),
|
||||
CONSTRAINT user_skills_name_size CHECK ((octet_length(name) <= 256))
|
||||
);
|
||||
|
||||
CREATE TABLE user_status_changes (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
@@ -3806,6 +3877,9 @@ ALTER TABLE ONLY user_links
|
||||
ALTER TABLE ONLY user_secrets
|
||||
ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY user_skills
|
||||
ADD CONSTRAINT user_skills_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY user_status_changes
|
||||
ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -4142,6 +4216,8 @@ CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree
|
||||
|
||||
CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name);
|
||||
|
||||
CREATE UNIQUE INDEX user_skills_user_id_name_idx ON user_skills USING btree (user_id, name);
|
||||
|
||||
CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text));
|
||||
|
||||
CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);
|
||||
@@ -4266,6 +4342,10 @@ CREATE TRIGGER trigger_upsert_user_links BEFORE INSERT OR UPDATE ON user_links F
|
||||
|
||||
CREATE TRIGGER trigger_upsert_user_secrets BEFORE INSERT OR UPDATE ON user_secrets FOR EACH ROW EXECUTE FUNCTION insert_user_secret_fail_if_user_deleted();
|
||||
|
||||
CREATE TRIGGER trigger_upsert_user_skills BEFORE INSERT OR UPDATE ON user_skills FOR EACH ROW EXECUTE FUNCTION insert_user_skill_fail_if_user_deleted();
|
||||
|
||||
CREATE TRIGGER trigger_user_skills_per_user_limit BEFORE INSERT ON user_skills FOR EACH ROW EXECUTE FUNCTION enforce_user_skills_per_user_limit();
|
||||
|
||||
CREATE TRIGGER update_notification_message_dedupe_hash BEFORE INSERT OR UPDATE ON notification_messages FOR EACH ROW EXECUTE FUNCTION compute_notification_message_dedupe_hash();
|
||||
|
||||
CREATE TRIGGER user_status_change_trigger AFTER INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION record_user_status_change();
|
||||
@@ -4591,6 +4671,9 @@ ALTER TABLE ONLY user_secrets
|
||||
ALTER TABLE ONLY user_secrets
|
||||
ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest);
|
||||
|
||||
ALTER TABLE ONLY user_skills
|
||||
ADD CONSTRAINT user_skills_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY user_status_changes
|
||||
ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ const (
|
||||
ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserSecretsUserID ForeignKeyConstraint = "user_secrets_user_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserSecretsValueKeyID ForeignKeyConstraint = "user_secrets_value_key_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest);
|
||||
ForeignKeyUserSkillsUserID ForeignKeyConstraint = "user_skills_user_id_fkey" // ALTER TABLE ONLY user_skills ADD CONSTRAINT user_skills_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceAgentDevcontainersSubagentID ForeignKeyConstraint = "workspace_agent_devcontainers_subagent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Enum additions to resource_type and api_key_scope are intentionally not
|
||||
-- reverted because Postgres cannot drop enum values safely.
|
||||
DROP TRIGGER IF EXISTS trigger_upsert_user_skills ON user_skills;
|
||||
DROP FUNCTION IF EXISTS insert_user_skill_fail_if_user_deleted;
|
||||
|
||||
-- Restore the previous body of delete_deleted_user_resources() from
|
||||
-- migration 000492 (without the user_skills cleanup).
|
||||
CREATE OR REPLACE FUNCTION delete_deleted_user_resources() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
IF (NEW.deleted) THEN
|
||||
-- Remove their api_keys.
|
||||
DELETE FROM api_keys
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_links.
|
||||
-- Their login_type is preserved in the users table.
|
||||
-- Matching this user back to the link can still be done by their
|
||||
-- email if the account is undeleted. Although that is not a guarantee.
|
||||
DELETE FROM user_links
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_secrets.
|
||||
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
|
||||
-- does not remove the users row so the FK cascade never fires.
|
||||
DELETE FROM user_secrets
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their organization memberships.
|
||||
-- This also triggers group membership cleanup via
|
||||
-- trigger_delete_group_members_on_org_member_delete.
|
||||
DELETE FROM organization_members
|
||||
WHERE user_id = OLD.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_user_skills_per_user_limit ON user_skills;
|
||||
DROP FUNCTION IF EXISTS enforce_user_skills_per_user_limit();
|
||||
DROP TABLE user_skills;
|
||||
@@ -0,0 +1,138 @@
|
||||
-- Creates the user_skills table and indexes.
|
||||
CREATE TABLE user_skills (
|
||||
id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
content text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT user_skills_name_size CHECK (octet_length(name) <= 256),
|
||||
CONSTRAINT user_skills_name_format CHECK (name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
|
||||
CONSTRAINT user_skills_description_size CHECK (octet_length(description) <= 4096),
|
||||
CONSTRAINT user_skills_content_size CHECK (octet_length(content) <= 65536)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX user_skills_user_id_name_idx ON user_skills (user_id, name);
|
||||
|
||||
-- Enforces the per-user personal-skill cap at the schema level so the
|
||||
-- invariant survives any future refactor of InsertUserSkill. The cap
|
||||
-- value must stay in sync with skills.MaxPersonalSkillsPerUser in Go.
|
||||
CREATE FUNCTION enforce_user_skills_per_user_limit() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
skill_count int;
|
||||
skill_limit constant int := 100;
|
||||
BEGIN
|
||||
-- Serialize skill-cap checks per user so concurrent inserts cannot all
|
||||
-- observe the same pre-insert count and exceed the hard limit.
|
||||
PERFORM 1
|
||||
FROM users
|
||||
WHERE id = NEW.user_id
|
||||
FOR UPDATE;
|
||||
|
||||
SELECT count(*) INTO skill_count
|
||||
FROM user_skills
|
||||
WHERE user_id = NEW.user_id;
|
||||
IF skill_count >= skill_limit THEN
|
||||
RAISE EXCEPTION 'user has reached the personal skill limit'
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_skills_per_user_limit';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_user_skills_per_user_limit
|
||||
BEFORE INSERT ON user_skills
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE enforce_user_skills_per_user_limit();
|
||||
|
||||
-- Extend the soft-delete cleanup trigger to also wipe user_skills.
|
||||
-- user_skills.user_id has ON DELETE CASCADE, but Coder soft-deletes
|
||||
-- users by flipping users.deleted instead of removing the row, so the
|
||||
-- FK cascade never fires and skills would otherwise survive deletion.
|
||||
DELETE FROM
|
||||
user_skills
|
||||
WHERE
|
||||
user_id
|
||||
IN (
|
||||
SELECT id FROM users WHERE deleted
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION delete_deleted_user_resources() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
IF (NEW.deleted) THEN
|
||||
-- Remove their api_keys.
|
||||
DELETE FROM api_keys
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_links.
|
||||
-- Their login_type is preserved in the users table.
|
||||
-- Matching this user back to the link can still be done by their
|
||||
-- email if the account is undeleted. Although that is not a guarantee.
|
||||
DELETE FROM user_links
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_secrets.
|
||||
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
|
||||
-- does not remove the users row so the FK cascade never fires.
|
||||
DELETE FROM user_secrets
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their organization memberships.
|
||||
-- This also triggers group membership cleanup via
|
||||
-- trigger_delete_group_members_on_org_member_delete.
|
||||
DELETE FROM organization_members
|
||||
WHERE user_id = OLD.id;
|
||||
|
||||
-- Remove their user_skills.
|
||||
-- user_skills.user_id has ON DELETE CASCADE, but soft-delete
|
||||
-- does not remove the users row so the FK cascade never fires.
|
||||
DELETE FROM user_skills
|
||||
WHERE user_id = OLD.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Prevent adding new user_skills for soft-deleted users.
|
||||
-- Closes the window between an in-flight CreateUserSkill request and
|
||||
-- the soft-delete UPDATE committing.
|
||||
CREATE FUNCTION insert_user_skill_fail_if_user_deleted() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
|
||||
BEGIN
|
||||
PERFORM 1
|
||||
FROM users
|
||||
WHERE id = NEW.user_id
|
||||
AND deleted = true
|
||||
LIMIT 1;
|
||||
IF FOUND THEN
|
||||
RAISE EXCEPTION 'Cannot create user_skill for deleted user'
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_skill_user_deleted';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_upsert_user_skills
|
||||
BEFORE INSERT OR UPDATE ON user_skills
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE insert_user_skill_fail_if_user_deleted();
|
||||
|
||||
-- Adds the user skill audit resource type.
|
||||
ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'user_skill';
|
||||
|
||||
-- Adds API key scopes for managing user skills.
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:create';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:read';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:update';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:delete';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_skill:*';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Inserts a user skill fixture so migration coverage includes the table.
|
||||
INSERT INTO user_skills (
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
description,
|
||||
content,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'7f070eb2-991e-4f7f-b780-40c4e0f49001',
|
||||
'30095c71-380b-457a-8995-97b8ee6e5307',
|
||||
'example-skill',
|
||||
'Example skill fixture.',
|
||||
'Example content.',
|
||||
'2026-05-07 00:00:00+00',
|
||||
'2026-05-07 00:00:00+00'
|
||||
);
|
||||
@@ -896,6 +896,10 @@ func (m WorkspaceAgentVolumeResourceMonitor) Debounce(
|
||||
return m.DebouncedUntil, false
|
||||
}
|
||||
|
||||
func (s UserSkill) RBACObject() rbac.Object {
|
||||
return rbac.ResourceUserSkill.WithID(s.ID).WithOwner(s.UserID.String())
|
||||
}
|
||||
|
||||
func (s UserSecret) RBACObject() rbac.Object {
|
||||
return rbac.ResourceUserSecret.WithID(s.ID).WithOwner(s.UserID.String())
|
||||
}
|
||||
|
||||
@@ -312,6 +312,11 @@ const (
|
||||
ApiKeyScopeAiProviderRead APIKeyScope = "ai_provider:read"
|
||||
ApiKeyScopeAiProviderUpdate APIKeyScope = "ai_provider:update"
|
||||
ApiKeyScopeChatShare APIKeyScope = "chat:share"
|
||||
ApiKeyScopeUserSkillCreate APIKeyScope = "user_skill:create"
|
||||
ApiKeyScopeUserSkillRead APIKeyScope = "user_skill:read"
|
||||
ApiKeyScopeUserSkillUpdate APIKeyScope = "user_skill:update"
|
||||
ApiKeyScopeUserSkillDelete APIKeyScope = "user_skill:delete"
|
||||
ApiKeyScopeUserSkill APIKeyScope = "user_skill:*"
|
||||
)
|
||||
|
||||
func (e *APIKeyScope) Scan(src interface{}) error {
|
||||
@@ -567,7 +572,12 @@ func (e APIKeyScope) Valid() bool {
|
||||
ApiKeyScopeAiProviderDelete,
|
||||
ApiKeyScopeAiProviderRead,
|
||||
ApiKeyScopeAiProviderUpdate,
|
||||
ApiKeyScopeChatShare:
|
||||
ApiKeyScopeChatShare,
|
||||
ApiKeyScopeUserSkillCreate,
|
||||
ApiKeyScopeUserSkillRead,
|
||||
ApiKeyScopeUserSkillUpdate,
|
||||
ApiKeyScopeUserSkillDelete,
|
||||
ApiKeyScopeUserSkill:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -792,6 +802,11 @@ func AllAPIKeyScopeValues() []APIKeyScope {
|
||||
ApiKeyScopeAiProviderRead,
|
||||
ApiKeyScopeAiProviderUpdate,
|
||||
ApiKeyScopeChatShare,
|
||||
ApiKeyScopeUserSkillCreate,
|
||||
ApiKeyScopeUserSkillRead,
|
||||
ApiKeyScopeUserSkillUpdate,
|
||||
ApiKeyScopeUserSkillDelete,
|
||||
ApiKeyScopeUserSkill,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3322,6 +3337,7 @@ const (
|
||||
ResourceTypeAiProvider ResourceType = "ai_provider"
|
||||
ResourceTypeAiProviderKey ResourceType = "ai_provider_key"
|
||||
ResourceTypeGroupAiBudget ResourceType = "group_ai_budget"
|
||||
ResourceTypeUserSkill ResourceType = "user_skill"
|
||||
)
|
||||
|
||||
func (e *ResourceType) Scan(src interface{}) error {
|
||||
@@ -3392,7 +3408,8 @@ func (e ResourceType) Valid() bool {
|
||||
ResourceTypeUserSecret,
|
||||
ResourceTypeAiProvider,
|
||||
ResourceTypeAiProviderKey,
|
||||
ResourceTypeGroupAiBudget:
|
||||
ResourceTypeGroupAiBudget,
|
||||
ResourceTypeUserSkill:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -3432,6 +3449,7 @@ func AllResourceTypeValues() []ResourceType {
|
||||
ResourceTypeAiProvider,
|
||||
ResourceTypeAiProviderKey,
|
||||
ResourceTypeGroupAiBudget,
|
||||
ResourceTypeUserSkill,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5725,6 +5743,16 @@ type UserSecret struct {
|
||||
ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"`
|
||||
}
|
||||
|
||||
type UserSkill struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
Content string `db:"content" json:"content"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// Tracks the history of user status changes
|
||||
type UserStatusChange struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
|
||||
@@ -196,6 +196,7 @@ type sqlcQuerier interface {
|
||||
DeleteUserChatCompactionThreshold(ctx context.Context, arg DeleteUserChatCompactionThresholdParams) error
|
||||
DeleteUserChatProviderKey(ctx context.Context, arg DeleteUserChatProviderKeyParams) error
|
||||
DeleteUserSecretByUserIDAndName(ctx context.Context, arg DeleteUserSecretByUserIDAndNameParams) (UserSecret, error)
|
||||
DeleteUserSkillByUserIDAndName(ctx context.Context, arg DeleteUserSkillByUserIDAndNameParams) (UserSkill, error)
|
||||
DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error
|
||||
DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error
|
||||
DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error
|
||||
@@ -791,6 +792,7 @@ type sqlcQuerier interface {
|
||||
// values rather than interpolating between rows.
|
||||
GetUserSecretsTelemetrySummary(ctx context.Context) (GetUserSecretsTelemetrySummaryRow, error)
|
||||
GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error)
|
||||
GetUserSkillByUserIDAndName(ctx context.Context, arg GetUserSkillByUserIDAndNameParams) (UserSkill, error)
|
||||
// GetUserStatusCounts returns the count of users in each status over time.
|
||||
// The time range is inclusively defined by the start_time and end_time parameters.
|
||||
GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error)
|
||||
@@ -966,6 +968,7 @@ type sqlcQuerier interface {
|
||||
// If there is a conflict, the user is already a member
|
||||
InsertUserGroupsByID(ctx context.Context, arg InsertUserGroupsByIDParams) ([]uuid.UUID, error)
|
||||
InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error)
|
||||
InsertUserSkill(ctx context.Context, arg InsertUserSkillParams) (UserSkill, error)
|
||||
InsertVolumeResourceMonitor(ctx context.Context, arg InsertVolumeResourceMonitorParams) (WorkspaceAgentVolumeResourceMonitor, error)
|
||||
// Inserts or updates a webpush subscription. The (user_id, endpoint) pair
|
||||
// is unique; re-subscribing the same endpoint replaces the keys instead of
|
||||
@@ -1033,6 +1036,7 @@ type sqlcQuerier interface {
|
||||
// provisioner (build-time injection) and the agent manifest
|
||||
// (runtime injection).
|
||||
ListUserSecretsWithValues(ctx context.Context, userID uuid.UUID) ([]UserSecret, error)
|
||||
ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]ListUserSkillMetadataByUserIDRow, error)
|
||||
ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error)
|
||||
MarkAllInboxNotificationsAsRead(ctx context.Context, arg MarkAllInboxNotificationsAsReadParams) error
|
||||
OIDCClaimFieldValues(ctx context.Context, arg OIDCClaimFieldValuesParams) ([]string, error)
|
||||
@@ -1271,6 +1275,7 @@ type sqlcQuerier interface {
|
||||
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
|
||||
UpdateUserSecretByUserIDAndName(ctx context.Context, arg UpdateUserSecretByUserIDAndNameParams) (UserSecret, error)
|
||||
UpdateUserShellToolDisplayMode(ctx context.Context, arg UpdateUserShellToolDisplayModeParams) (string, error)
|
||||
UpdateUserSkillByUserIDAndName(ctx context.Context, arg UpdateUserSkillByUserIDAndNameParams) (UserSkill, error)
|
||||
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
|
||||
UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error)
|
||||
UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error)
|
||||
|
||||
@@ -26894,6 +26894,177 @@ func (q *sqlQuerier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg Up
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteUserSkillByUserIDAndName = `-- name: DeleteUserSkillByUserIDAndName :one
|
||||
DELETE FROM user_skills
|
||||
WHERE user_id = $1 AND name = $2
|
||||
RETURNING id, user_id, name, description, content, created_at, updated_at
|
||||
`
|
||||
|
||||
type DeleteUserSkillByUserIDAndNameParams struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) DeleteUserSkillByUserIDAndName(ctx context.Context, arg DeleteUserSkillByUserIDAndNameParams) (UserSkill, error) {
|
||||
row := q.db.QueryRowContext(ctx, deleteUserSkillByUserIDAndName, arg.UserID, arg.Name)
|
||||
var i UserSkill
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Content,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSkillByUserIDAndName = `-- name: GetUserSkillByUserIDAndName :one
|
||||
SELECT id, user_id, name, description, content, created_at, updated_at
|
||||
FROM user_skills
|
||||
WHERE user_id = $1 AND name = $2
|
||||
`
|
||||
|
||||
type GetUserSkillByUserIDAndNameParams struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetUserSkillByUserIDAndName(ctx context.Context, arg GetUserSkillByUserIDAndNameParams) (UserSkill, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserSkillByUserIDAndName, arg.UserID, arg.Name)
|
||||
var i UserSkill
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Content,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertUserSkill = `-- name: InsertUserSkill :one
|
||||
INSERT INTO user_skills (id, user_id, name, description, content)
|
||||
VALUES ($1::uuid, $2::uuid, $3::text, $4::text, $5::text)
|
||||
RETURNING id, user_id, name, description, content, created_at, updated_at
|
||||
`
|
||||
|
||||
type InsertUserSkillParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
Content string `db:"content" json:"content"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertUserSkill(ctx context.Context, arg InsertUserSkillParams) (UserSkill, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertUserSkill,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Content,
|
||||
)
|
||||
var i UserSkill
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Content,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listUserSkillMetadataByUserID = `-- name: ListUserSkillMetadataByUserID :many
|
||||
SELECT
|
||||
id, user_id, name, description, created_at, updated_at
|
||||
FROM user_skills
|
||||
WHERE user_id = $1
|
||||
ORDER BY name ASC
|
||||
`
|
||||
|
||||
type ListUserSkillMetadataByUserIDRow struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]ListUserSkillMetadataByUserIDRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUserSkillMetadataByUserID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserSkillMetadataByUserIDRow
|
||||
for rows.Next() {
|
||||
var i ListUserSkillMetadataByUserIDRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); 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 updateUserSkillByUserIDAndName = `-- name: UpdateUserSkillByUserIDAndName :one
|
||||
UPDATE user_skills
|
||||
SET
|
||||
description = $1,
|
||||
content = $2,
|
||||
updated_at = now()
|
||||
WHERE user_id = $3 AND name = $4
|
||||
RETURNING id, user_id, name, description, content, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateUserSkillByUserIDAndNameParams struct {
|
||||
Description string `db:"description" json:"description"`
|
||||
Content string `db:"content" json:"content"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateUserSkillByUserIDAndName(ctx context.Context, arg UpdateUserSkillByUserIDAndNameParams) (UserSkill, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateUserSkillByUserIDAndName,
|
||||
arg.Description,
|
||||
arg.Content,
|
||||
arg.UserID,
|
||||
arg.Name,
|
||||
)
|
||||
var i UserSkill
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Content,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteUserChatProviderKey = `-- name: DeleteUserChatProviderKey :exec
|
||||
DELETE FROM user_chat_provider_keys WHERE user_id = $1 AND chat_provider_id = $2
|
||||
`
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- name: InsertUserSkill :one
|
||||
INSERT INTO user_skills (id, user_id, name, description, content)
|
||||
VALUES (@id::uuid, @user_id::uuid, @name::text, @description::text, @content::text)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUserSkillByUserIDAndName :one
|
||||
SELECT *
|
||||
FROM user_skills
|
||||
WHERE user_id = @user_id AND name = @name;
|
||||
|
||||
-- name: ListUserSkillMetadataByUserID :many
|
||||
SELECT
|
||||
id, user_id, name, description, created_at, updated_at
|
||||
FROM user_skills
|
||||
WHERE user_id = @user_id
|
||||
ORDER BY name ASC;
|
||||
|
||||
-- name: UpdateUserSkillByUserIDAndName :one
|
||||
UPDATE user_skills
|
||||
SET
|
||||
description = @description,
|
||||
content = @content,
|
||||
updated_at = now()
|
||||
WHERE user_id = @user_id AND name = @name
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteUserSkillByUserIDAndName :one
|
||||
DELETE FROM user_skills
|
||||
WHERE user_id = @user_id AND name = @name
|
||||
RETURNING *;
|
||||
@@ -103,6 +103,7 @@ const (
|
||||
UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id);
|
||||
UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type);
|
||||
UniqueUserSecretsPkey UniqueConstraint = "user_secrets_pkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id);
|
||||
UniqueUserSkillsPkey UniqueConstraint = "user_skills_pkey" // ALTER TABLE ONLY user_skills ADD CONSTRAINT user_skills_pkey PRIMARY KEY (id);
|
||||
UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id);
|
||||
UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
UniqueWebpushSubscriptionsPkey UniqueConstraint = "webpush_subscriptions_pkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id);
|
||||
@@ -156,6 +157,7 @@ const (
|
||||
UniqueUserSecretsUserEnvNameIndex UniqueConstraint = "user_secrets_user_env_name_idx" // CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets USING btree (user_id, env_name) WHERE (env_name <> ''::text);
|
||||
UniqueUserSecretsUserFilePathIndex UniqueConstraint = "user_secrets_user_file_path_idx" // CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree (user_id, file_path) WHERE (file_path <> ''::text);
|
||||
UniqueUserSecretsUserNameIndex UniqueConstraint = "user_secrets_user_name_idx" // CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name);
|
||||
UniqueUserSkillsUserIDNameIndex UniqueConstraint = "user_skills_user_id_name_idx" // CREATE UNIQUE INDEX user_skills_user_id_name_idx ON user_skills USING btree (user_id, name);
|
||||
UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text));
|
||||
UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);
|
||||
UniqueWebpushSubscriptionsUserIDEndpointIndex UniqueConstraint = "webpush_subscriptions_user_id_endpoint_idx" // CREATE UNIQUE INDEX webpush_subscriptions_user_id_endpoint_idx ON webpush_subscriptions USING btree (user_id, endpoint);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/x/skills"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestUserSkillSchemaConstants(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
_, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
|
||||
var triggerDef string
|
||||
err := sqlDB.QueryRowContext(ctx,
|
||||
`SELECT pg_get_functiondef('enforce_user_skills_per_user_limit'::regproc)`,
|
||||
).Scan(&triggerDef)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, triggerDef, fmt.Sprintf(
|
||||
"skill_limit constant int := %d",
|
||||
skills.MaxPersonalSkillsPerUser,
|
||||
))
|
||||
|
||||
constraints := map[database.CheckConstraint]string{
|
||||
database.CheckUserSkillsNameSize: fmt.Sprintf(
|
||||
"octet_length(name) <= %d",
|
||||
skills.MaxPersonalSkillNameBytes,
|
||||
),
|
||||
database.CheckUserSkillsNameFormat: "name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text",
|
||||
database.CheckUserSkillsDescriptionSize: fmt.Sprintf(
|
||||
"octet_length(description) <= %d",
|
||||
skills.MaxPersonalSkillDescriptionBytes,
|
||||
),
|
||||
database.CheckUserSkillsContentSize: fmt.Sprintf(
|
||||
"octet_length(content) <= %d",
|
||||
skills.MaxPersonalSkillSizeBytes,
|
||||
),
|
||||
}
|
||||
for constraint, expected := range constraints {
|
||||
t.Run(string(constraint), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
var constraintDef string
|
||||
err := sqlDB.QueryRowContext(ctx,
|
||||
`SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = $1`,
|
||||
constraint,
|
||||
).Scan(&constraintDef)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, constraintDef, expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user