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:
Michael Suchacz
2026-05-20 00:09:09 +02:00
committed by GitHub
parent 3c9c8c708d
commit 5a8d0016a5
46 changed files with 2315 additions and 70 deletions
+4
View File
@@ -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
)
+34
View File
@@ -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
}
+40
View File
@@ -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)
+55
View File
@@ -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{
+40
View File
@@ -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)
+75
View File
@@ -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()
+107 -24
View File
@@ -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'
);
+4
View File
@@ -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())
}
+30 -2
View File
@@ -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"`
+5
View File
@@ -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)
+171
View File
@@ -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
`
+30
View File
@@ -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 *;
+2
View File
@@ -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);
+62
View File
@@ -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)
})
}
}