From 46ca00df317e9400d785642ff781d21ac6deb37c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 16 Jan 2024 16:48:25 +0000 Subject: [PATCH 01/27] feat(coderd): add user_pinned_workspaces table --- coderd/database/dump.sql | 5 +++++ .../000185_user_pinned_workspaces.down.sql | 1 + .../migrations/000185_user_pinned_workspaces.up.sql | 4 ++++ .../fixtures/000185_user_pinned_workspaces.up.sql | 13 +++++++++++++ coderd/database/models.go | 5 +++++ 5 files changed, 28 insertions(+) create mode 100644 coderd/database/migrations/000185_user_pinned_workspaces.down.sql create mode 100644 coderd/database/migrations/000185_user_pinned_workspaces.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000185_user_pinned_workspaces.up.sql diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f9d1e4311b2b2..7db56e7cc9338 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -915,6 +915,11 @@ COMMENT ON COLUMN user_links.oauth_refresh_token_key_id IS 'The ID of the key us COMMENT ON COLUMN user_links.debug_context IS 'Debug information includes information like id_token and userinfo claims.'; +CREATE TABLE user_pinned_workspaces ( + user_id uuid NOT NULL, + workspace_id uuid NOT NULL +); + CREATE TABLE workspace_agent_log_sources ( workspace_agent_id uuid NOT NULL, id uuid NOT NULL, diff --git a/coderd/database/migrations/000185_user_pinned_workspaces.down.sql b/coderd/database/migrations/000185_user_pinned_workspaces.down.sql new file mode 100644 index 0000000000000..21c7fbbecfe58 --- /dev/null +++ b/coderd/database/migrations/000185_user_pinned_workspaces.down.sql @@ -0,0 +1 @@ +DROP TABLE user_pinned_workspaces; diff --git a/coderd/database/migrations/000185_user_pinned_workspaces.up.sql b/coderd/database/migrations/000185_user_pinned_workspaces.up.sql new file mode 100644 index 0000000000000..f8e027538ed69 --- /dev/null +++ b/coderd/database/migrations/000185_user_pinned_workspaces.up.sql @@ -0,0 +1,4 @@ +CREATE TABLE user_pinned_workspaces ( + user_id uuid NOT NULL, + workspace_id uuid NOT NULL +); diff --git a/coderd/database/migrations/testdata/fixtures/000185_user_pinned_workspaces.up.sql b/coderd/database/migrations/testdata/fixtures/000185_user_pinned_workspaces.up.sql new file mode 100644 index 0000000000000..97d719d1eed8b --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000185_user_pinned_workspaces.up.sql @@ -0,0 +1,13 @@ +INSERT INTO user_pinned_workspaces + (user_id, workspace_id) +VALUES ( + '30095c71-380b-457a-8995-97b8ee6e5307', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe' +); + +INSERT INTO user_pinned_workspaces + (user_id, workspace_id) +VALUES ( + '0ed9befc-4911-4ccf-a8e2-559bf72daa94', + 'b90547be-8870-4d68-8184-e8b2242b7c01' +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 5308f88b35a79..e43e8d455fbca 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2163,6 +2163,11 @@ type UserLink struct { DebugContext json.RawMessage `db:"debug_context" json:"debug_context"` } +type UserPinnedWorkspace struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` +} + // Visible fields of users are allowed to be joined with other tables for including context of other resources. type VisibleUser struct { ID uuid.UUID `db:"id" json:"id"` From 015aabb4bb1b89429d2aa5b4647c2770b2e903a1 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 17 Jan 2024 12:34:42 +0000 Subject: [PATCH 02/27] add unique index --- coderd/database/dump.sql | 3 +++ .../database/migrations/000185_user_pinned_workspaces.up.sql | 3 ++- coderd/database/unique_constraint.go | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7db56e7cc9338..4d2edbb5b1e2e 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1378,6 +1378,9 @@ ALTER TABLE ONLY templates ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); +ALTER TABLE ONLY user_pinned_workspaces + ADD CONSTRAINT user_pinned_workspaces_user_id_workspace_id_key UNIQUE (user_id, workspace_id); + ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); diff --git a/coderd/database/migrations/000185_user_pinned_workspaces.up.sql b/coderd/database/migrations/000185_user_pinned_workspaces.up.sql index f8e027538ed69..f5df0dc36b8d5 100644 --- a/coderd/database/migrations/000185_user_pinned_workspaces.up.sql +++ b/coderd/database/migrations/000185_user_pinned_workspaces.up.sql @@ -1,4 +1,5 @@ CREATE TABLE user_pinned_workspaces ( user_id uuid NOT NULL, - workspace_id uuid NOT NULL + workspace_id uuid NOT NULL, + UNIQUE(user_id, workspace_id) ); diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index f397692f1d6d1..9e90b7da4c34c 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -47,6 +47,7 @@ const ( UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_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); + UniqueUserPinnedWorkspacesUserIDWorkspaceIDKey UniqueConstraint = "user_pinned_workspaces_user_id_workspace_id_key" // ALTER TABLE ONLY user_pinned_workspaces ADD CONSTRAINT user_pinned_workspaces_user_id_workspace_id_key UNIQUE (user_id, workspace_id); UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id); UniqueWorkspaceAgentMetadataPkey UniqueConstraint = "workspace_agent_metadata_pkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_pkey PRIMARY KEY (workspace_agent_id, key); From 0b0dce613f1b52ac814400e6f95d2345950e21d2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 17 Jan 2024 12:36:50 +0000 Subject: [PATCH 03/27] rename migration --- ...workspaces.down.sql => 000186_user_pinned_workspaces.down.sql} | 0 ...ned_workspaces.up.sql => 000186_user_pinned_workspaces.up.sql} | 0 ...ned_workspaces.up.sql => 000186_user_pinned_workspaces.up.sql} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000185_user_pinned_workspaces.down.sql => 000186_user_pinned_workspaces.down.sql} (100%) rename coderd/database/migrations/{000185_user_pinned_workspaces.up.sql => 000186_user_pinned_workspaces.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000185_user_pinned_workspaces.up.sql => 000186_user_pinned_workspaces.up.sql} (100%) diff --git a/coderd/database/migrations/000185_user_pinned_workspaces.down.sql b/coderd/database/migrations/000186_user_pinned_workspaces.down.sql similarity index 100% rename from coderd/database/migrations/000185_user_pinned_workspaces.down.sql rename to coderd/database/migrations/000186_user_pinned_workspaces.down.sql diff --git a/coderd/database/migrations/000185_user_pinned_workspaces.up.sql b/coderd/database/migrations/000186_user_pinned_workspaces.up.sql similarity index 100% rename from coderd/database/migrations/000185_user_pinned_workspaces.up.sql rename to coderd/database/migrations/000186_user_pinned_workspaces.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000185_user_pinned_workspaces.up.sql b/coderd/database/migrations/testdata/fixtures/000186_user_pinned_workspaces.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000185_user_pinned_workspaces.up.sql rename to coderd/database/migrations/testdata/fixtures/000186_user_pinned_workspaces.up.sql From 9878da77ea6df045034845d05729b23fc0d11854 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 17 Jan 2024 13:32:35 +0000 Subject: [PATCH 04/27] add queries to pin/unpin workspace --- coderd/database/dbauthz/dbauthz.go | 14 ++++++++ coderd/database/dbauthz/dbauthz_test.go | 16 +++++++++ coderd/database/dbmem/dbmem.go | 43 +++++++++++++++++++++++++ coderd/database/dbmetrics/dbmetrics.go | 14 ++++++++ coderd/database/dbmock/dbmock.go | 28 ++++++++++++++++ coderd/database/querier.go | 2 ++ coderd/database/queries.sql.go | 28 ++++++++++++++++ coderd/database/queries/workspaces.sql | 6 ++++ 8 files changed, 151 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index a5b295e2e35eb..7078505c85926 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2475,6 +2475,13 @@ func (q *querier) InsertWorkspaceResourceMetadata(ctx context.Context, arg datab return q.db.InsertWorkspaceResourceMetadata(ctx, arg) } +func (q *querier) PinWorkspace(ctx context.Context, arg database.PinWorkspaceParams) error { + if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { + return err + } + return q.db.PinWorkspace(ctx, arg) +} + func (q *querier) RegisterWorkspaceProxy(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { fetch := func(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { return q.db.GetWorkspaceProxyByID(ctx, arg.ID) @@ -2509,6 +2516,13 @@ func (q *querier) UnarchiveTemplateVersion(ctx context.Context, arg database.Una return q.db.UnarchiveTemplateVersion(ctx, arg) } +func (q *querier) UnpinWorkspace(ctx context.Context, arg database.UnpinWorkspaceParams) error { + if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { + return err + } + return q.db.UnpinWorkspace(ctx, arg) +} + func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error { fetch := func(ctx context.Context, arg database.UpdateAPIKeyByIDParams) (database.APIKey, error) { return q.db.GetAPIKeyByID(ctx, arg.ID) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index d9444278722e7..2222803862824 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1578,6 +1578,22 @@ func (s *MethodTestSuite) TestWorkspace() { WorkspaceID: ws.ID, }).Asserts(ws, rbac.ActionUpdate).Returns() })) + s.Run("PinWorkspace", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID}) + check.Args(database.PinWorkspaceParams{ + UserID: u.ID, + WorkspaceID: ws.ID, + }).Asserts(ws, rbac.ActionRead).Returns() + })) + s.Run("UnpinWorkspace", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID}) + check.Args(database.UnpinWorkspaceParams{ + UserID: u.ID, + WorkspaceID: ws.ID, + }).Asserts(ws, rbac.ActionRead).Returns() + })) } func (s *MethodTestSuite) TestExtraMethods() { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 0800fb5dd0a54..2428fda65d2fd 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -71,6 +71,7 @@ func New() database.Store { workspaceBuilds: make([]database.WorkspaceBuildTable, 0), workspaceApps: make([]database.WorkspaceApp, 0), workspaces: make([]database.Workspace, 0), + userPinnedWorkspaces: make([]database.UserPinnedWorkspace, 0), licenses: make([]database.License, 0), workspaceProxies: make([]database.WorkspaceProxy, 0), locks: map[int64]struct{}{}, @@ -154,6 +155,7 @@ type data struct { workspaceResourceMetadata []database.WorkspaceResourceMetadatum workspaceResources []database.WorkspaceResource workspaces []database.Workspace + userPinnedWorkspaces []database.UserPinnedWorkspace workspaceProxies []database.WorkspaceProxy // Locks is a map of lock names. Any keys within the map are currently // locked. @@ -5901,6 +5903,23 @@ func (q *FakeQuerier) InsertWorkspaceResourceMetadata(_ context.Context, arg dat return metadata, nil } +func (q *FakeQuerier) PinWorkspace(_ context.Context, arg database.PinWorkspaceParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, upw := range q.userPinnedWorkspaces { + if arg.UserID == upw.UserID && arg.WorkspaceID == upw.WorkspaceID { + return errDuplicateKey + } + } + return nil +} + func (q *FakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -5984,6 +6003,30 @@ func (q *FakeQuerier) UnarchiveTemplateVersion(_ context.Context, arg database.U return sql.ErrNoRows } +func (q *FakeQuerier) UnpinWorkspace(_ context.Context, arg database.UnpinWorkspaceParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, upw := range q.userPinnedWorkspaces { + if upw.UserID != arg.UserID { + continue + } + if upw.WorkspaceID != arg.WorkspaceID { + continue + } + q.userPinnedWorkspaces[index] = q.userPinnedWorkspaces[len(q.apiKeys)-1] + q.userPinnedWorkspaces = q.userPinnedWorkspaces[:len(q.userPinnedWorkspaces)-1] + return nil + } + + return nil +} + func (q *FakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPIKeyByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 625871500dbeb..04e1ed36fb37b 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1586,6 +1586,13 @@ func (m metricsStore) InsertWorkspaceResourceMetadata(ctx context.Context, arg d return metadata, err } +func (m metricsStore) PinWorkspace(ctx context.Context, arg database.PinWorkspaceParams) error { + start := time.Now() + r0 := m.s.PinWorkspace(ctx, arg) + m.queryLatencies.WithLabelValues("PinWorkspace").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) RegisterWorkspaceProxy(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { start := time.Now() proxy, err := m.s.RegisterWorkspaceProxy(ctx, arg) @@ -1614,6 +1621,13 @@ func (m metricsStore) UnarchiveTemplateVersion(ctx context.Context, arg database return r0 } +func (m metricsStore) UnpinWorkspace(ctx context.Context, arg database.UnpinWorkspaceParams) error { + start := time.Now() + r0 := m.s.UnpinWorkspace(ctx, arg) + m.queryLatencies.WithLabelValues("UnpinWorkspace").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error { start := time.Now() err := m.s.UpdateAPIKeyByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index bfb93405f5524..b88e7b9fdb11e 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3337,6 +3337,20 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceResourceMetadata(arg0, arg1 any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceResourceMetadata", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceResourceMetadata), arg0, arg1) } +// PinWorkspace mocks base method. +func (m *MockStore) PinWorkspace(arg0 context.Context, arg1 database.PinWorkspaceParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PinWorkspace", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// PinWorkspace indicates an expected call of PinWorkspace. +func (mr *MockStoreMockRecorder) PinWorkspace(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PinWorkspace", reflect.TypeOf((*MockStore)(nil).PinWorkspace), arg0, arg1) +} + // Ping mocks base method. func (m *MockStore) Ping(arg0 context.Context) (time.Duration, error) { m.ctrl.T.Helper() @@ -3410,6 +3424,20 @@ func (mr *MockStoreMockRecorder) UnarchiveTemplateVersion(arg0, arg1 any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnarchiveTemplateVersion", reflect.TypeOf((*MockStore)(nil).UnarchiveTemplateVersion), arg0, arg1) } +// UnpinWorkspace mocks base method. +func (m *MockStore) UnpinWorkspace(arg0 context.Context, arg1 database.UnpinWorkspaceParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnpinWorkspace", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnpinWorkspace indicates an expected call of UnpinWorkspace. +func (mr *MockStoreMockRecorder) UnpinWorkspace(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnpinWorkspace", reflect.TypeOf((*MockStore)(nil).UnpinWorkspace), arg0, arg1) +} + // UpdateAPIKeyByID mocks base method. func (m *MockStore) UpdateAPIKeyByID(arg0 context.Context, arg1 database.UpdateAPIKeyByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 8947ba185d14d..87d199cd26087 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -312,6 +312,7 @@ type sqlcQuerier interface { InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error) InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error) + PinWorkspace(ctx context.Context, arg PinWorkspaceParams) error RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error // Non blocking lock. Returns true if the lock was acquired, false otherwise. @@ -321,6 +322,7 @@ type sqlcQuerier interface { TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) // This will always work regardless of the current state of the template version. UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error + UnpinWorkspace(ctx context.Context, arg UnpinWorkspaceParams) error UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4c4bfc6012e7b..c5e14f19e1fbc 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11631,6 +11631,34 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar return i, err } +const pinWorkspace = `-- name: PinWorkspace :exec +INSERT INTO user_pinned_workspaces (user_id, workspace_id) VALUES ($1, $2) +` + +type PinWorkspaceParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` +} + +func (q *sqlQuerier) PinWorkspace(ctx context.Context, arg PinWorkspaceParams) error { + _, err := q.db.ExecContext(ctx, pinWorkspace, arg.UserID, arg.WorkspaceID) + return err +} + +const unpinWorkspace = `-- name: UnpinWorkspace :exec +DELETE FROM user_pinned_workspaces WHERE user_id = $1 AND workspace_id = $2 +` + +type UnpinWorkspaceParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` +} + +func (q *sqlQuerier) UnpinWorkspace(ctx context.Context, arg UnpinWorkspaceParams) error { + _, err := q.db.ExecContext(ctx, unpinWorkspace, arg.UserID, arg.WorkspaceID) + return err +} + const updateTemplateWorkspacesLastUsedAt = `-- name: UpdateTemplateWorkspacesLastUsedAt :exec UPDATE workspaces SET diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index b400a1165b292..124d01522162f 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -547,3 +547,9 @@ SET automatic_updates = $2 WHERE id = $1; + +-- name: PinWorkspace :exec +INSERT INTO user_pinned_workspaces (user_id, workspace_id) VALUES (sqlc.arg(user_id), sqlc.arg(workspace_id)); + +-- name: UnpinWorkspace :exec +DELETE FROM user_pinned_workspaces WHERE user_id = sqlc.arg(user_id) AND workspace_id = sqlc.arg(workspace_id); From 4fba238d519159c6c26f1690b0efae4f74cf716b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 17 Jan 2024 17:13:13 +0000 Subject: [PATCH 05/27] add pinned status to GetWorkspaces/GetAuthorizedWorkspaces queries --- coderd/database/modelqueries.go | 3 +- coderd/database/queries.sql.go | 47 ++++++++++++++++++-------- coderd/database/queries/workspaces.sql | 15 ++++++++ 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 7443f1231a848..5464065007158 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -213,9 +213,9 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa // The name comment is for metric tracking query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s", filtered) rows, err := q.db.QueryContext(ctx, query, + arg.OwnerID, arg.Deleted, arg.Status, - arg.OwnerID, arg.OwnerUsername, arg.TemplateName, pq.Array(arg.TemplateIDs), @@ -253,6 +253,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, + &i.Pinned, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c5e14f19e1fbc..fb80a73c3eb2c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11170,6 +11170,7 @@ SELECT COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, + (upw.user_id IS NOT NULL)::boolean AS pinned, COUNT(*) OVER () as count FROM workspaces @@ -11214,34 +11215,47 @@ LEFT JOIN LATERAL ( WHERE templates.id = workspaces.template_id ) template_name ON true +LEFT JOIN LATERAL ( + SELECT + user_id + FROM + user_pinned_workspaces + WHERE + workspaces.id = user_pinned_workspaces.workspace_id + AND + -- Omitting the owner_id parameter will result in + -- 00000000-0000-0000-0000-000000000000 which will not match + -- any rows in user_pinned_workspaces. + user_pinned_workspaces.user_id = $1 +) upw ON TRUE WHERE -- Optionally include deleted workspaces - workspaces.deleted = $1 + workspaces.deleted = $2 AND CASE - WHEN $2 :: text != '' THEN + WHEN $3 :: text != '' THEN CASE -- Some workspace specific status refer to the transition -- type. By default, the standard provisioner job status -- search strings are supported. -- 'running' states - WHEN $2 = 'starting' THEN + WHEN $3 = 'starting' THEN latest_build.job_status = 'running'::provisioner_job_status AND latest_build.transition = 'start'::workspace_transition - WHEN $2 = 'stopping' THEN + WHEN $3 = 'stopping' THEN latest_build.job_status = 'running'::provisioner_job_status AND latest_build.transition = 'stop'::workspace_transition - WHEN $2 = 'deleting' THEN + WHEN $3 = 'deleting' THEN latest_build.job_status = 'running' AND latest_build.transition = 'delete'::workspace_transition -- 'succeeded' states - WHEN $2 = 'deleted' THEN + WHEN $3 = 'deleted' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'delete'::workspace_transition - WHEN $2 = 'stopped' THEN + WHEN $3 = 'stopped' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'stop'::workspace_transition - WHEN $2 = 'started' THEN + WHEN $3 = 'started' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'start'::workspace_transition @@ -11249,13 +11263,13 @@ WHERE -- differ. A workspace is "running" if the job is "succeeded" and -- the transition is "start". This is because a workspace starts -- running when a job is complete. - WHEN $2 = 'running' THEN + WHEN $3 = 'running' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'start'::workspace_transition - WHEN $2 != '' THEN + WHEN $3 != '' THEN -- By default just match the job status exactly - latest_build.job_status = $2::provisioner_job_status + latest_build.job_status = $3::provisioner_job_status ELSE true END @@ -11263,8 +11277,8 @@ WHERE END -- Filter by owner_id AND CASE - WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - workspaces.owner_id = $3 + WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspaces.owner_id = $1 ELSE true END -- Filter by owner_name @@ -11350,6 +11364,7 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY + pinned DESC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND @@ -11366,9 +11381,9 @@ OFFSET ` type GetWorkspacesParams struct { + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` Deleted bool `db:"deleted" json:"deleted"` Status string `db:"status" json:"status"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` OwnerUsername string `db:"owner_username" json:"owner_username"` TemplateName string `db:"template_name" json:"template_name"` TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` @@ -11400,14 +11415,15 @@ type GetWorkspacesRow struct { TemplateName string `db:"template_name" json:"template_name"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` + Pinned bool `db:"pinned" json:"pinned"` Count int64 `db:"count" json:"count"` } func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) { rows, err := q.db.QueryContext(ctx, getWorkspaces, + arg.OwnerID, arg.Deleted, arg.Status, - arg.OwnerID, arg.OwnerUsername, arg.TemplateName, pq.Array(arg.TemplateIDs), @@ -11445,6 +11461,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, + &i.Pinned, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 124d01522162f..35555012f7398 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -82,6 +82,7 @@ SELECT COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, + (upw.user_id IS NOT NULL)::boolean AS pinned, COUNT(*) OVER () as count FROM workspaces @@ -126,6 +127,19 @@ LEFT JOIN LATERAL ( WHERE templates.id = workspaces.template_id ) template_name ON true +LEFT JOIN LATERAL ( + SELECT + user_id + FROM + user_pinned_workspaces + WHERE + workspaces.id = user_pinned_workspaces.workspace_id + AND + -- Omitting the owner_id parameter will result in + -- 00000000-0000-0000-0000-000000000000 which will not match + -- any rows in user_pinned_workspaces. + user_pinned_workspaces.user_id = @owner_id +) upw ON TRUE WHERE -- Optionally include deleted workspaces workspaces.deleted = @deleted @@ -262,6 +276,7 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY + pinned DESC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND From 83b03d9aa581b4afb8295df1bc7aec8f3499c119 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 18 Jan 2024 14:47:27 +0000 Subject: [PATCH 06/27] add API endpoints and test (currently fails) --- coderd/apidoc/docs.go | 62 +++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 54 +++++++++++++++++++++++ coderd/audit/diff.go | 1 + coderd/coderd.go | 2 + coderd/workspaces.go | 88 ++++++++++++++++++++++++++++++++++++++ coderd/workspaces_test.go | 75 ++++++++++++++++++++++++++++++++ codersdk/workspaces.go | 25 +++++++++++ docs/api/workspaces.md | 52 ++++++++++++++++++++++ 8 files changed, 359 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e17e2d8081180..e069728f24984 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6999,6 +6999,68 @@ const docTemplate = `{ } } }, + "/workspaces/{workspace}/pin": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "Workspaces" + ], + "summary": "Pin workspace by ID.", + "operationId": "pin-workspace-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "Workspaces" + ], + "summary": "Unpin workspace by ID.", + "operationId": "unpin-workspace-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/workspaces/{workspace}/resolve-autostart": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 34b4bd36df2a3..f5be088b011a3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6175,6 +6175,60 @@ } } }, + "/workspaces/{workspace}/pin": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "tags": ["Workspaces"], + "summary": "Pin workspace by ID.", + "operationId": "pin-workspace-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "tags": ["Workspaces"], + "summary": "Unpin workspace by ID.", + "operationId": "unpin-workspace-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/workspaces/{workspace}/resolve-autostart": { "get": { "security": [ diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index bdaef00bb082b..2d10d07b0fd11 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -13,6 +13,7 @@ type Auditable interface { database.TemplateVersion | database.User | database.Workspace | + database.UserPinnedWorkspace | database.GitSSHKey | database.WorkspaceBuild | database.AuditableGroup | diff --git a/coderd/coderd.go b/coderd/coderd.go index e3c935971f3e3..fc36cd14e5e0c 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -950,6 +950,8 @@ func New(options *Options) *API { r.Get("/watch", api.watchWorkspace) r.Put("/extend", api.putExtendWorkspace) r.Put("/dormant", api.putWorkspaceDormant) + r.Put("/pin", api.putWorkspacePin) + r.Delete("/pin", api.deleteWorkspacePin) r.Put("/autoupdates", api.putWorkspaceAutoupdates) r.Get("/resolve-autostart", api.resolveAutostart) }) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0bcd8bb6dd9e8..054918ee7cd6c 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1021,6 +1021,93 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, code, resp) } +// @Summary Pin workspace by ID. +// @ID pin-workspace-by-id +// @Security CoderSessionToken +// @Accept json +// @Tags Workspaces +// @Param workspace path string true "Workspace ID" format(uuid) +// @Success 204 +// @Router /workspaces/{workspace}/pin [put] +func (api *API) putWorkspacePin(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + apiKey = httpmw.APIKey(r) + workspace = httpmw.WorkspaceParam(r) + auditor = api.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.UserPinnedWorkspace](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + }) + ) + defer commitAudit() + aReq.Old = database.UserPinnedWorkspace{} + + err := api.Database.PinWorkspace(ctx, database.PinWorkspaceParams{ + UserID: apiKey.UserID, + WorkspaceID: workspace.ID, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error pinning workspace", + Detail: err.Error(), + }) + return + } + + aReq.New = database.UserPinnedWorkspace{ + UserID: apiKey.UserID, + WorkspaceID: workspace.ID, + } + + rw.WriteHeader(http.StatusNoContent) +} + +// @Summary Unpin workspace by ID. +// @ID unpin-workspace-by-id +// @Security CoderSessionToken +// @Accept json +// @Tags Workspaces +// @Param workspace path string true "Workspace ID" format(uuid) +// @Success 204 +// @Router /workspaces/{workspace}/pin [delete] +func (api *API) deleteWorkspacePin(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + apiKey = httpmw.APIKey(r) + workspace = httpmw.WorkspaceParam(r) + auditor = api.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.UserPinnedWorkspace](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + }) + ) + defer commitAudit() + aReq.Old = database.UserPinnedWorkspace{ + UserID: apiKey.UserID, + WorkspaceID: workspace.ID, + } + + err := api.Database.UnpinWorkspace(ctx, database.UnpinWorkspaceParams{ + UserID: apiKey.UserID, + WorkspaceID: workspace.ID, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error unpinning workspace", + Detail: err.Error(), + }) + return + } + aReq.New = database.UserPinnedWorkspace{} + + rw.WriteHeader(http.StatusNoContent) +} + // @Summary Update workspace automatic updates by ID // @ID update-workspace-automatic-updates-by-id // @Security CoderSessionToken @@ -1472,6 +1559,7 @@ func convertWorkspace( }, AutomaticUpdates: codersdk.AutomaticUpdates(workspace.AutomaticUpdates), AllowRenames: allowRenames, + // Pinned: pinned, // TODO } } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 18566f6b3cdf1..23b9dec8828dc 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2927,4 +2927,79 @@ func TestWorkspaceDormant(t *testing.T) { require.NoError(t, err) coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) }) + + t.Run("PinUnpin", func(t *testing.T) { + t.Parallel() + // Given: + var ( + auditRecorder = audit.NewMock() + client = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + Auditor: auditRecorder, + }) + owner = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template = coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + memberClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + workspace = coderdtest.CreateWorkspace(t, memberClient, owner.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + ) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Initially, workspace should not be pinned. + workspaces, err := memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 1) + require.False(t, workspaces.Workspaces[0].Pinned) + ws, err := memberClient.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.False(t, ws.Pinned) + + // When member pins workspace + err = memberClient.PinWorkspace(ctx, workspace.ID) + require.NoError(t, err) + + // Then it should be pinned for them + workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 1) + require.True(t, workspaces.Workspaces[0].Pinned) + ws, err = memberClient.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.True(t, ws.Pinned) + + // But not for someone else + workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 1) + require.False(t, workspaces.Workspaces[0].Pinned) + ws, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.False(t, ws.Pinned) + + // When member unpins workspace + err = memberClient.UnpinWorkspace(ctx, workspace.ID) + require.NoError(t, err) + + // Then it should no longer be pinned for them + workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 1) + require.False(t, workspaces.Workspaces[0].Pinned) + ws, err = memberClient.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.False(t, ws.Pinned) + + // Assert invariant: workspace should remain unpinned for a different user + workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 1) + require.False(t, workspaces.Workspaces[0].Pinned) + ws, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.False(t, ws.Pinned) + }) } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 11f07b91aa789..ce5b81579f431 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -58,6 +58,7 @@ type Workspace struct { Health WorkspaceHealth `json:"health"` AutomaticUpdates AutomaticUpdates `json:"automatic_updates" enums:"always,never"` AllowRenames bool `json:"allow_renames"` + Pinned bool `json:"pinned"` } func (w Workspace) FullName() string { @@ -471,6 +472,30 @@ func (c *Client) ResolveAutostart(ctx context.Context, workspaceID string) (Reso return response, json.NewDecoder(res.Body).Decode(&response) } +func (c *Client) PinWorkspace(ctx context.Context, workspaceID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/workspaces/%s/pin", workspaceID), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return err + } + return nil +} + +func (c *Client) UnpinWorkspace(ctx context.Context, workspaceID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/workspaces/%s/pin", workspaceID), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return err + } + return nil +} + // WorkspaceNotifyChannel is the PostgreSQL NOTIFY // channel to listen for updates on. The payload is empty, // because the size of a workspace payload can be very large. diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index da06dc4874e90..2cd3f71700a27 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -1245,6 +1245,58 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/extend \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Pin workspace by ID. + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/pin \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /workspaces/{workspace}/pin` + +### Parameters + +| Name | In | Type | Required | Description | +| ----------- | ---- | ------------ | -------- | ------------ | +| `workspace` | path | string(uuid) | true | Workspace ID | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Unpin workspace by ID. + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/workspaces/{workspace}/pin \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /workspaces/{workspace}/pin` + +### Parameters + +| Name | In | Type | Required | Description | +| ----------- | ---- | ------------ | -------- | ------------ | +| `workspace` | path | string(uuid) | true | Workspace ID | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Resolve workspace autostart by id. ### Code samples From 0961298b99c05bd6f1f1db2fc6ff21a99ff848a3 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 18 Jan 2024 19:27:26 +0000 Subject: [PATCH 07/27] rename to favorite workspace --- coderd/apidoc/docs.go | 15 ++-- coderd/apidoc/swagger.json | 15 ++-- coderd/audit.go | 5 ++ coderd/audit/diff.go | 2 +- coderd/audit/request.go | 6 ++ coderd/coderd.go | 4 +- coderd/database/dbauthz/dbauthz.go | 28 +++--- coderd/database/dbauthz/dbauthz_test.go | 8 +- coderd/database/dbmem/dbmem.go | 86 +++++++++---------- coderd/database/dbmetrics/dbmetrics.go | 20 ++--- coderd/database/dbmock/dbmock.go | 40 ++++----- coderd/database/dump.sql | 19 ++-- .../000186_user_favorite_workspaces.down.sql | 1 + .../000186_user_favorite_workspaces.up.sql | 7 ++ .../000186_user_pinned_workspaces.down.sql | 1 - .../000186_user_pinned_workspaces.up.sql | 5 -- ...=> 000186_user_favorite_workspaces.up.sql} | 4 +- coderd/database/modelqueries.go | 2 +- coderd/database/models.go | 41 +++++---- coderd/database/querier.go | 4 +- coderd/database/queries.sql.go | 56 ++++++------ coderd/database/queries/workspaces.sql | 22 ++--- coderd/database/unique_constraint.go | 2 +- coderd/workspaces.go | 32 +++---- coderd/workspaces_test.go | 71 ++++++++------- codersdk/audit.go | 29 ++++--- codersdk/workspaces.go | 10 +-- docs/api/schemas.md | 34 ++++---- docs/api/workspaces.md | 17 ++-- site/src/api/typesGenerated.ts | 3 + 30 files changed, 318 insertions(+), 271 deletions(-) create mode 100644 coderd/database/migrations/000186_user_favorite_workspaces.down.sql create mode 100644 coderd/database/migrations/000186_user_favorite_workspaces.up.sql delete mode 100644 coderd/database/migrations/000186_user_pinned_workspaces.down.sql delete mode 100644 coderd/database/migrations/000186_user_pinned_workspaces.up.sql rename coderd/database/migrations/testdata/fixtures/{000186_user_pinned_workspaces.up.sql => 000186_user_favorite_workspaces.up.sql} (77%) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e069728f24984..ae9b59e59a556 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6999,7 +6999,7 @@ const docTemplate = `{ } } }, - "/workspaces/{workspace}/pin": { + "/workspaces/{workspace}/favorite": { "put": { "security": [ { @@ -7012,8 +7012,8 @@ const docTemplate = `{ "tags": [ "Workspaces" ], - "summary": "Pin workspace by ID.", - "operationId": "pin-workspace-by-id", + "summary": "Favor workspace by ID.", + "operationId": "favorite-workspace-by-id", "parameters": [ { "type": "string", @@ -7042,8 +7042,8 @@ const docTemplate = `{ "tags": [ "Workspaces" ], - "summary": "Unpin workspace by ID.", - "operationId": "unpin-workspace-by-id", + "summary": "Unfavor workspace by ID.", + "operationId": "unfavorite-workspace-by-id", "parameters": [ { "type": "string", @@ -10582,6 +10582,7 @@ const docTemplate = `{ "user", "workspace", "workspace_build", + "favorite_workspace", "git_ssh_key", "api_key", "group", @@ -10597,6 +10598,7 @@ const docTemplate = `{ "ResourceTypeUser", "ResourceTypeWorkspace", "ResourceTypeWorkspaceBuild", + "ResourceTypeFavoriteWorkspace", "ResourceTypeGitSSHKey", "ResourceTypeAPIKey", "ResourceTypeGroup", @@ -12024,6 +12026,9 @@ const docTemplate = `{ "owner_name": { "type": "string" }, + "pinned": { + "type": "boolean" + }, "template_active_version_id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f5be088b011a3..764794188152b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6175,7 +6175,7 @@ } } }, - "/workspaces/{workspace}/pin": { + "/workspaces/{workspace}/favorite": { "put": { "security": [ { @@ -6184,8 +6184,8 @@ ], "consumes": ["application/json"], "tags": ["Workspaces"], - "summary": "Pin workspace by ID.", - "operationId": "pin-workspace-by-id", + "summary": "Favor workspace by ID.", + "operationId": "favorite-workspace-by-id", "parameters": [ { "type": "string", @@ -6210,8 +6210,8 @@ ], "consumes": ["application/json"], "tags": ["Workspaces"], - "summary": "Unpin workspace by ID.", - "operationId": "unpin-workspace-by-id", + "summary": "Unfavor workspace by ID.", + "operationId": "unfavorite-workspace-by-id", "parameters": [ { "type": "string", @@ -9530,6 +9530,7 @@ "user", "workspace", "workspace_build", + "favorite_workspace", "git_ssh_key", "api_key", "group", @@ -9545,6 +9546,7 @@ "ResourceTypeUser", "ResourceTypeWorkspace", "ResourceTypeWorkspaceBuild", + "ResourceTypeFavoriteWorkspace", "ResourceTypeGitSSHKey", "ResourceTypeAPIKey", "ResourceTypeGroup", @@ -10900,6 +10902,9 @@ "owner_name": { "type": "string" }, + "pinned": { + "type": "boolean" + }, "template_active_version_id": { "type": "string", "format": "uuid" diff --git a/coderd/audit.go b/coderd/audit.go index d6b20a292916c..3cf6e7a5628fe 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -271,6 +271,11 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string { return str } + // "Pinning" (favoriting) a workspace is a separate thing. + if alog.ResourceType == database.ResourceTypeFavoriteWorkspace { + return fmt.Sprintf("{user} pinned workspace %s", alog.ResourceTarget) + } + str += fmt.Sprintf(" %s", codersdk.ResourceType(alog.ResourceType).FriendlyString()) diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 2d10d07b0fd11..632a0acf5fb69 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -13,7 +13,7 @@ type Auditable interface { database.TemplateVersion | database.User | database.Workspace | - database.UserPinnedWorkspace | + database.FavoriteWorkspace | database.GitSSHKey | database.WorkspaceBuild | database.AuditableGroup | diff --git a/coderd/audit/request.go b/coderd/audit/request.go index c61db9ef83914..1aba5a3e17d58 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -95,6 +95,8 @@ func ResourceTarget[T Auditable](tgt T) string { return string(typed.ToLoginType) case database.HealthSettings: return "" // no target? + case database.FavoriteWorkspace: + return typed.WorkspaceID.String() default: panic(fmt.Sprintf("unknown resource %T", tgt)) } @@ -128,6 +130,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { case database.HealthSettings: // Artificial ID for auditing purposes return typed.ID + case database.FavoriteWorkspace: + return typed.WorkspaceID default: panic(fmt.Sprintf("unknown resource %T", tgt)) } @@ -159,6 +163,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeConvertLogin case database.HealthSettings: return database.ResourceTypeHealthSettings + case database.FavoriteWorkspace: + return database.ResourceTypeFavoriteWorkspace default: panic(fmt.Sprintf("unknown resource %T", typed)) } diff --git a/coderd/coderd.go b/coderd/coderd.go index fc36cd14e5e0c..e43cec63e8303 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -950,8 +950,8 @@ func New(options *Options) *API { r.Get("/watch", api.watchWorkspace) r.Put("/extend", api.putExtendWorkspace) r.Put("/dormant", api.putWorkspaceDormant) - r.Put("/pin", api.putWorkspacePin) - r.Delete("/pin", api.deleteWorkspacePin) + r.Put("/pin", api.putFavoriteWorkspace) + r.Delete("/pin", api.deleteFavoriteWorkspace) r.Put("/autoupdates", api.putWorkspaceAutoupdates) r.Get("/resolve-autostart", api.resolveAutostart) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 7078505c85926..15ceb8cb06a5f 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -656,6 +656,20 @@ func authorizedTemplateVersionFromJob(ctx context.Context, q *querier, job datab } } +func (q *querier) FavoriteWorkspace(ctx context.Context, arg database.FavoriteWorkspaceParams) error { + if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { + return err + } + return q.db.FavoriteWorkspace(ctx, arg) +} + +func (q *querier) UnfavoriteWorkspace(ctx context.Context, arg database.UnfavoriteWorkspaceParams) error { + if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { + return err + } + return q.db.UnfavoriteWorkspace(ctx, arg) +} + func (q *querier) AcquireLock(ctx context.Context, id int64) error { return q.db.AcquireLock(ctx, id) } @@ -2475,13 +2489,6 @@ func (q *querier) InsertWorkspaceResourceMetadata(ctx context.Context, arg datab return q.db.InsertWorkspaceResourceMetadata(ctx, arg) } -func (q *querier) PinWorkspace(ctx context.Context, arg database.PinWorkspaceParams) error { - if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { - return err - } - return q.db.PinWorkspace(ctx, arg) -} - func (q *querier) RegisterWorkspaceProxy(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { fetch := func(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { return q.db.GetWorkspaceProxyByID(ctx, arg.ID) @@ -2516,13 +2523,6 @@ func (q *querier) UnarchiveTemplateVersion(ctx context.Context, arg database.Una return q.db.UnarchiveTemplateVersion(ctx, arg) } -func (q *querier) UnpinWorkspace(ctx context.Context, arg database.UnpinWorkspaceParams) error { - if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { - return err - } - return q.db.UnpinWorkspace(ctx, arg) -} - func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error { fetch := func(ctx context.Context, arg database.UpdateAPIKeyByIDParams) (database.APIKey, error) { return q.db.GetAPIKeyByID(ctx, arg.ID) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 2222803862824..d81a857a4c3a8 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1578,18 +1578,18 @@ func (s *MethodTestSuite) TestWorkspace() { WorkspaceID: ws.ID, }).Asserts(ws, rbac.ActionUpdate).Returns() })) - s.Run("PinWorkspace", s.Subtest(func(db database.Store, check *expects) { + s.Run("FavoriteWorkspace", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID}) - check.Args(database.PinWorkspaceParams{ + check.Args(database.FavoriteWorkspaceParams{ UserID: u.ID, WorkspaceID: ws.ID, }).Asserts(ws, rbac.ActionRead).Returns() })) - s.Run("UnpinWorkspace", s.Subtest(func(db database.Store, check *expects) { + s.Run("UnfavoriteWorkspace", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID}) - check.Args(database.UnpinWorkspaceParams{ + check.Args(database.UnfavoriteWorkspaceParams{ UserID: u.ID, WorkspaceID: ws.ID, }).Asserts(ws, rbac.ActionRead).Returns() diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 2428fda65d2fd..48667b8c474f1 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -71,7 +71,7 @@ func New() database.Store { workspaceBuilds: make([]database.WorkspaceBuildTable, 0), workspaceApps: make([]database.WorkspaceApp, 0), workspaces: make([]database.Workspace, 0), - userPinnedWorkspaces: make([]database.UserPinnedWorkspace, 0), + favoriteWorkspaces: make([]database.FavoriteWorkspace, 0), licenses: make([]database.License, 0), workspaceProxies: make([]database.WorkspaceProxy, 0), locks: map[int64]struct{}{}, @@ -155,7 +155,7 @@ type data struct { workspaceResourceMetadata []database.WorkspaceResourceMetadatum workspaceResources []database.WorkspaceResource workspaces []database.Workspace - userPinnedWorkspaces []database.UserPinnedWorkspace + favoriteWorkspaces []database.FavoriteWorkspace workspaceProxies []database.WorkspaceProxy // Locks is a map of lock names. Any keys within the map are currently // locked. @@ -732,6 +732,47 @@ func isNotNull(v interface{}) bool { return reflect.ValueOf(v).FieldByName("Valid").Bool() } +func (q *FakeQuerier) FavoriteWorkspace(_ context.Context, arg database.FavoriteWorkspaceParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, upw := range q.favoriteWorkspaces { + if arg.UserID == upw.UserID && arg.WorkspaceID == upw.WorkspaceID { + return errDuplicateKey + } + } + return nil +} + +func (q *FakeQuerier) UnfavoriteWorkspace(_ context.Context, arg database.UnfavoriteWorkspaceParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, upw := range q.favoriteWorkspaces { + if upw.UserID != arg.UserID { + continue + } + if upw.WorkspaceID != arg.WorkspaceID { + continue + } + q.favoriteWorkspaces[index] = q.favoriteWorkspaces[len(q.apiKeys)-1] + q.favoriteWorkspaces = q.favoriteWorkspaces[:len(q.favoriteWorkspaces)-1] + return nil + } + + return nil +} + func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } @@ -5903,23 +5944,6 @@ func (q *FakeQuerier) InsertWorkspaceResourceMetadata(_ context.Context, arg dat return metadata, nil } -func (q *FakeQuerier) PinWorkspace(_ context.Context, arg database.PinWorkspaceParams) error { - err := validateDatabaseType(arg) - if err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - for _, upw := range q.userPinnedWorkspaces { - if arg.UserID == upw.UserID && arg.WorkspaceID == upw.WorkspaceID { - return errDuplicateKey - } - } - return nil -} - func (q *FakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -6003,30 +6027,6 @@ func (q *FakeQuerier) UnarchiveTemplateVersion(_ context.Context, arg database.U return sql.ErrNoRows } -func (q *FakeQuerier) UnpinWorkspace(_ context.Context, arg database.UnpinWorkspaceParams) error { - err := validateDatabaseType(arg) - if err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - for index, upw := range q.userPinnedWorkspaces { - if upw.UserID != arg.UserID { - continue - } - if upw.WorkspaceID != arg.WorkspaceID { - continue - } - q.userPinnedWorkspaces[index] = q.userPinnedWorkspaces[len(q.apiKeys)-1] - q.userPinnedWorkspaces = q.userPinnedWorkspaces[:len(q.userPinnedWorkspaces)-1] - return nil - } - - return nil -} - func (q *FakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPIKeyByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 04e1ed36fb37b..2cda3d6e29931 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -300,6 +300,13 @@ func (m metricsStore) DeleteTailnetTunnel(ctx context.Context, arg database.Dele return r0, r1 } +func (m metricsStore) FavoriteWorkspace(ctx context.Context, arg database.FavoriteWorkspaceParams) error { + start := time.Now() + r0 := m.s.FavoriteWorkspace(ctx, arg) + m.queryLatencies.WithLabelValues("FavoriteWorkspace").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { start := time.Now() apiKey, err := m.s.GetAPIKeyByID(ctx, id) @@ -1586,13 +1593,6 @@ func (m metricsStore) InsertWorkspaceResourceMetadata(ctx context.Context, arg d return metadata, err } -func (m metricsStore) PinWorkspace(ctx context.Context, arg database.PinWorkspaceParams) error { - start := time.Now() - r0 := m.s.PinWorkspace(ctx, arg) - m.queryLatencies.WithLabelValues("PinWorkspace").Observe(time.Since(start).Seconds()) - return r0 -} - func (m metricsStore) RegisterWorkspaceProxy(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { start := time.Now() proxy, err := m.s.RegisterWorkspaceProxy(ctx, arg) @@ -1621,10 +1621,10 @@ func (m metricsStore) UnarchiveTemplateVersion(ctx context.Context, arg database return r0 } -func (m metricsStore) UnpinWorkspace(ctx context.Context, arg database.UnpinWorkspaceParams) error { +func (m metricsStore) UnfavoriteWorkspace(ctx context.Context, arg database.UnfavoriteWorkspaceParams) error { start := time.Now() - r0 := m.s.UnpinWorkspace(ctx, arg) - m.queryLatencies.WithLabelValues("UnpinWorkspace").Observe(time.Since(start).Seconds()) + r0 := m.s.UnfavoriteWorkspace(ctx, arg) + m.queryLatencies.WithLabelValues("UnfavoriteWorkspace").Observe(time.Since(start).Seconds()) return r0 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b88e7b9fdb11e..57a8b3e556112 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -500,6 +500,20 @@ func (mr *MockStoreMockRecorder) DeleteTailnetTunnel(arg0, arg1 any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTailnetTunnel", reflect.TypeOf((*MockStore)(nil).DeleteTailnetTunnel), arg0, arg1) } +// FavoriteWorkspace mocks base method. +func (m *MockStore) FavoriteWorkspace(arg0 context.Context, arg1 database.FavoriteWorkspaceParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FavoriteWorkspace", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// FavoriteWorkspace indicates an expected call of FavoriteWorkspace. +func (mr *MockStoreMockRecorder) FavoriteWorkspace(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FavoriteWorkspace", reflect.TypeOf((*MockStore)(nil).FavoriteWorkspace), arg0, arg1) +} + // GetAPIKeyByID mocks base method. func (m *MockStore) GetAPIKeyByID(arg0 context.Context, arg1 string) (database.APIKey, error) { m.ctrl.T.Helper() @@ -3337,20 +3351,6 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceResourceMetadata(arg0, arg1 any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceResourceMetadata", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceResourceMetadata), arg0, arg1) } -// PinWorkspace mocks base method. -func (m *MockStore) PinWorkspace(arg0 context.Context, arg1 database.PinWorkspaceParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PinWorkspace", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 -} - -// PinWorkspace indicates an expected call of PinWorkspace. -func (mr *MockStoreMockRecorder) PinWorkspace(arg0, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PinWorkspace", reflect.TypeOf((*MockStore)(nil).PinWorkspace), arg0, arg1) -} - // Ping mocks base method. func (m *MockStore) Ping(arg0 context.Context) (time.Duration, error) { m.ctrl.T.Helper() @@ -3424,18 +3424,18 @@ func (mr *MockStoreMockRecorder) UnarchiveTemplateVersion(arg0, arg1 any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnarchiveTemplateVersion", reflect.TypeOf((*MockStore)(nil).UnarchiveTemplateVersion), arg0, arg1) } -// UnpinWorkspace mocks base method. -func (m *MockStore) UnpinWorkspace(arg0 context.Context, arg1 database.UnpinWorkspaceParams) error { +// UnfavoriteWorkspace mocks base method. +func (m *MockStore) UnfavoriteWorkspace(arg0 context.Context, arg1 database.UnfavoriteWorkspaceParams) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UnpinWorkspace", arg0, arg1) + ret := m.ctrl.Call(m, "UnfavoriteWorkspace", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } -// UnpinWorkspace indicates an expected call of UnpinWorkspace. -func (mr *MockStoreMockRecorder) UnpinWorkspace(arg0, arg1 any) *gomock.Call { +// UnfavoriteWorkspace indicates an expected call of UnfavoriteWorkspace. +func (mr *MockStoreMockRecorder) UnfavoriteWorkspace(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnpinWorkspace", reflect.TypeOf((*MockStore)(nil).UnpinWorkspace), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnfavoriteWorkspace", reflect.TypeOf((*MockStore)(nil).UnfavoriteWorkspace), arg0, arg1) } // UpdateAPIKeyByID mocks base method. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 4d2edbb5b1e2e..3b71841d5726c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -134,7 +134,8 @@ CREATE TYPE resource_type AS ENUM ( 'license', 'workspace_proxy', 'convert_login', - 'health_settings' + 'health_settings', + 'favorite_workspace' ); CREATE TYPE startup_script_behavior AS ENUM ( @@ -402,6 +403,11 @@ COMMENT ON COLUMN external_auth_links.oauth_access_token_key_id IS 'The ID of th COMMENT ON COLUMN external_auth_links.oauth_refresh_token_key_id IS 'The ID of the key used to encrypt the OAuth refresh token. If this is NULL, the refresh token is not encrypted'; +CREATE TABLE favorite_workspaces ( + user_id uuid NOT NULL, + workspace_id uuid NOT NULL +); + CREATE TABLE files ( hash character varying(64) NOT NULL, created_at timestamp with time zone NOT NULL, @@ -915,11 +921,6 @@ COMMENT ON COLUMN user_links.oauth_refresh_token_key_id IS 'The ID of the key us COMMENT ON COLUMN user_links.debug_context IS 'Debug information includes information like id_token and userinfo claims.'; -CREATE TABLE user_pinned_workspaces ( - user_id uuid NOT NULL, - workspace_id uuid NOT NULL -); - CREATE TABLE workspace_agent_log_sources ( workspace_agent_id uuid NOT NULL, id uuid NOT NULL, @@ -1273,6 +1274,9 @@ ALTER TABLE ONLY dbcrypt_keys ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_revoked_key_digest_key UNIQUE (revoked_key_digest); +ALTER TABLE ONLY favorite_workspaces + ADD CONSTRAINT favorite_workspaces_user_id_workspace_id_key UNIQUE (user_id, workspace_id); + ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); @@ -1378,9 +1382,6 @@ ALTER TABLE ONLY templates ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); -ALTER TABLE ONLY user_pinned_workspaces - ADD CONSTRAINT user_pinned_workspaces_user_id_workspace_id_key UNIQUE (user_id, workspace_id); - ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); diff --git a/coderd/database/migrations/000186_user_favorite_workspaces.down.sql b/coderd/database/migrations/000186_user_favorite_workspaces.down.sql new file mode 100644 index 0000000000000..da48786b4417c --- /dev/null +++ b/coderd/database/migrations/000186_user_favorite_workspaces.down.sql @@ -0,0 +1 @@ +DROP TABLE favorite_workspaces; diff --git a/coderd/database/migrations/000186_user_favorite_workspaces.up.sql b/coderd/database/migrations/000186_user_favorite_workspaces.up.sql new file mode 100644 index 0000000000000..b62999a44e581 --- /dev/null +++ b/coderd/database/migrations/000186_user_favorite_workspaces.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE favorite_workspaces ( + user_id uuid NOT NULL, + workspace_id uuid NOT NULL, + UNIQUE(user_id, workspace_id) +); + +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'favorite_workspace'; diff --git a/coderd/database/migrations/000186_user_pinned_workspaces.down.sql b/coderd/database/migrations/000186_user_pinned_workspaces.down.sql deleted file mode 100644 index 21c7fbbecfe58..0000000000000 --- a/coderd/database/migrations/000186_user_pinned_workspaces.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE user_pinned_workspaces; diff --git a/coderd/database/migrations/000186_user_pinned_workspaces.up.sql b/coderd/database/migrations/000186_user_pinned_workspaces.up.sql deleted file mode 100644 index f5df0dc36b8d5..0000000000000 --- a/coderd/database/migrations/000186_user_pinned_workspaces.up.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE user_pinned_workspaces ( - user_id uuid NOT NULL, - workspace_id uuid NOT NULL, - UNIQUE(user_id, workspace_id) -); diff --git a/coderd/database/migrations/testdata/fixtures/000186_user_pinned_workspaces.up.sql b/coderd/database/migrations/testdata/fixtures/000186_user_favorite_workspaces.up.sql similarity index 77% rename from coderd/database/migrations/testdata/fixtures/000186_user_pinned_workspaces.up.sql rename to coderd/database/migrations/testdata/fixtures/000186_user_favorite_workspaces.up.sql index 97d719d1eed8b..039456ce1f456 100644 --- a/coderd/database/migrations/testdata/fixtures/000186_user_pinned_workspaces.up.sql +++ b/coderd/database/migrations/testdata/fixtures/000186_user_favorite_workspaces.up.sql @@ -1,11 +1,11 @@ -INSERT INTO user_pinned_workspaces +INSERT INTO favorite_workspaces (user_id, workspace_id) VALUES ( '30095c71-380b-457a-8995-97b8ee6e5307', '3a9a1feb-e89d-457c-9d53-ac751b198ebe' ); -INSERT INTO user_pinned_workspaces +INSERT INTO favorite_workspaces (user_id, workspace_id) VALUES ( '0ed9befc-4911-4ccf-a8e2-559bf72daa94', diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 5464065007158..7e1700a465a9f 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -253,7 +253,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, - &i.Pinned, + &i.Favored, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/models.go b/coderd/database/models.go index e43e8d455fbca..2ce424c4d800e 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1146,19 +1146,20 @@ func AllProvisionerTypeValues() []ProvisionerType { type ResourceType string const ( - ResourceTypeOrganization ResourceType = "organization" - ResourceTypeTemplate ResourceType = "template" - ResourceTypeTemplateVersion ResourceType = "template_version" - ResourceTypeUser ResourceType = "user" - ResourceTypeWorkspace ResourceType = "workspace" - ResourceTypeGitSshKey ResourceType = "git_ssh_key" - ResourceTypeApiKey ResourceType = "api_key" - ResourceTypeGroup ResourceType = "group" - ResourceTypeWorkspaceBuild ResourceType = "workspace_build" - ResourceTypeLicense ResourceType = "license" - ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" - ResourceTypeConvertLogin ResourceType = "convert_login" - ResourceTypeHealthSettings ResourceType = "health_settings" + ResourceTypeOrganization ResourceType = "organization" + ResourceTypeTemplate ResourceType = "template" + ResourceTypeTemplateVersion ResourceType = "template_version" + ResourceTypeUser ResourceType = "user" + ResourceTypeWorkspace ResourceType = "workspace" + ResourceTypeGitSshKey ResourceType = "git_ssh_key" + ResourceTypeApiKey ResourceType = "api_key" + ResourceTypeGroup ResourceType = "group" + ResourceTypeWorkspaceBuild ResourceType = "workspace_build" + ResourceTypeLicense ResourceType = "license" + ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" + ResourceTypeConvertLogin ResourceType = "convert_login" + ResourceTypeHealthSettings ResourceType = "health_settings" + ResourceTypeFavoriteWorkspace ResourceType = "favorite_workspace" ) func (e *ResourceType) Scan(src interface{}) error { @@ -1210,7 +1211,8 @@ func (e ResourceType) Valid() bool { ResourceTypeLicense, ResourceTypeWorkspaceProxy, ResourceTypeConvertLogin, - ResourceTypeHealthSettings: + ResourceTypeHealthSettings, + ResourceTypeFavoriteWorkspace: return true } return false @@ -1231,6 +1233,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeWorkspaceProxy, ResourceTypeConvertLogin, ResourceTypeHealthSettings, + ResourceTypeFavoriteWorkspace, } } @@ -1745,6 +1748,11 @@ type ExternalAuthLink struct { OAuthExtra pqtype.NullRawMessage `db:"oauth_extra" json:"oauth_extra"` } +type FavoriteWorkspace struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` +} + type File struct { Hash string `db:"hash" json:"hash"` CreatedAt time.Time `db:"created_at" json:"created_at"` @@ -2163,11 +2171,6 @@ type UserLink struct { DebugContext json.RawMessage `db:"debug_context" json:"debug_context"` } -type UserPinnedWorkspace struct { - UserID uuid.UUID `db:"user_id" json:"user_id"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` -} - // Visible fields of users are allowed to be joined with other tables for including context of other resources. type VisibleUser struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 87d199cd26087..d19d91062efd0 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -75,6 +75,7 @@ type sqlcQuerier interface { DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) + FavoriteWorkspace(ctx context.Context, arg FavoriteWorkspaceParams) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) // there is no unique constraint on empty token names GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) @@ -312,7 +313,6 @@ type sqlcQuerier interface { InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error) InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error) - PinWorkspace(ctx context.Context, arg PinWorkspaceParams) error RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error // Non blocking lock. Returns true if the lock was acquired, false otherwise. @@ -322,7 +322,7 @@ type sqlcQuerier interface { TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) // This will always work regardless of the current state of the template version. UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error - UnpinWorkspace(ctx context.Context, arg UnpinWorkspaceParams) error + UnfavoriteWorkspace(ctx context.Context, arg UnfavoriteWorkspaceParams) error UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fb80a73c3eb2c..ad460f2afee3c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -10849,6 +10849,20 @@ func (q *sqlQuerier) BatchUpdateWorkspaceLastUsedAt(ctx context.Context, arg Bat return err } +const favoriteWorkspace = `-- name: FavoriteWorkspace :exec +INSERT INTO favorite_workspaces (user_id, workspace_id) VALUES ($1, $2) +` + +type FavoriteWorkspaceParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` +} + +func (q *sqlQuerier) FavoriteWorkspace(ctx context.Context, arg FavoriteWorkspaceParams) error { + _, err := q.db.ExecContext(ctx, favoriteWorkspace, arg.UserID, arg.WorkspaceID) + return err +} + const getDeploymentWorkspaceStats = `-- name: GetDeploymentWorkspaceStats :one WITH workspaces_with_jobs AS ( SELECT @@ -11170,7 +11184,7 @@ SELECT COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, - (upw.user_id IS NOT NULL)::boolean AS pinned, + (fws.user_id IS NOT NULL)::boolean AS favored, COUNT(*) OVER () as count FROM workspaces @@ -11219,15 +11233,15 @@ LEFT JOIN LATERAL ( SELECT user_id FROM - user_pinned_workspaces + favorite_workspaces WHERE - workspaces.id = user_pinned_workspaces.workspace_id + workspaces.id = favorite_workspaces.workspace_id AND -- Omitting the owner_id parameter will result in -- 00000000-0000-0000-0000-000000000000 which will not match - -- any rows in user_pinned_workspaces. - user_pinned_workspaces.user_id = $1 -) upw ON TRUE + -- any rows in favorite_workspaces. + favorite_workspaces.user_id = $1 +) fws ON TRUE WHERE -- Optionally include deleted workspaces workspaces.deleted = $2 @@ -11364,7 +11378,7 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY - pinned DESC, + favored DESC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND @@ -11415,7 +11429,7 @@ type GetWorkspacesRow struct { TemplateName string `db:"template_name" json:"template_name"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` - Pinned bool `db:"pinned" json:"pinned"` + Favored bool `db:"favored" json:"favored"` Count int64 `db:"count" json:"count"` } @@ -11461,7 +11475,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, - &i.Pinned, + &i.Favored, &i.Count, ); err != nil { return nil, err @@ -11648,31 +11662,17 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar return i, err } -const pinWorkspace = `-- name: PinWorkspace :exec -INSERT INTO user_pinned_workspaces (user_id, workspace_id) VALUES ($1, $2) -` - -type PinWorkspaceParams struct { - UserID uuid.UUID `db:"user_id" json:"user_id"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` -} - -func (q *sqlQuerier) PinWorkspace(ctx context.Context, arg PinWorkspaceParams) error { - _, err := q.db.ExecContext(ctx, pinWorkspace, arg.UserID, arg.WorkspaceID) - return err -} - -const unpinWorkspace = `-- name: UnpinWorkspace :exec -DELETE FROM user_pinned_workspaces WHERE user_id = $1 AND workspace_id = $2 +const unfavoriteWorkspace = `-- name: UnfavoriteWorkspace :exec +DELETE FROM favorite_workspaces WHERE user_id = $1 AND workspace_id = $2 ` -type UnpinWorkspaceParams struct { +type UnfavoriteWorkspaceParams struct { UserID uuid.UUID `db:"user_id" json:"user_id"` WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` } -func (q *sqlQuerier) UnpinWorkspace(ctx context.Context, arg UnpinWorkspaceParams) error { - _, err := q.db.ExecContext(ctx, unpinWorkspace, arg.UserID, arg.WorkspaceID) +func (q *sqlQuerier) UnfavoriteWorkspace(ctx context.Context, arg UnfavoriteWorkspaceParams) error { + _, err := q.db.ExecContext(ctx, unfavoriteWorkspace, arg.UserID, arg.WorkspaceID) return err } diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 35555012f7398..bbb9bb96c5dce 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -82,7 +82,7 @@ SELECT COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, - (upw.user_id IS NOT NULL)::boolean AS pinned, + (fws.user_id IS NOT NULL)::boolean AS favored, COUNT(*) OVER () as count FROM workspaces @@ -131,15 +131,15 @@ LEFT JOIN LATERAL ( SELECT user_id FROM - user_pinned_workspaces + favorite_workspaces WHERE - workspaces.id = user_pinned_workspaces.workspace_id + workspaces.id = favorite_workspaces.workspace_id AND -- Omitting the owner_id parameter will result in -- 00000000-0000-0000-0000-000000000000 which will not match - -- any rows in user_pinned_workspaces. - user_pinned_workspaces.user_id = @owner_id -) upw ON TRUE + -- any rows in favorite_workspaces. + favorite_workspaces.user_id = @owner_id +) fws ON TRUE WHERE -- Optionally include deleted workspaces workspaces.deleted = @deleted @@ -276,7 +276,7 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY - pinned DESC, + favored DESC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND @@ -563,8 +563,8 @@ SET WHERE id = $1; --- name: PinWorkspace :exec -INSERT INTO user_pinned_workspaces (user_id, workspace_id) VALUES (sqlc.arg(user_id), sqlc.arg(workspace_id)); +-- name: FavoriteWorkspace :exec +INSERT INTO favorite_workspaces (user_id, workspace_id) VALUES (sqlc.arg(user_id), sqlc.arg(workspace_id)); --- name: UnpinWorkspace :exec -DELETE FROM user_pinned_workspaces WHERE user_id = sqlc.arg(user_id) AND workspace_id = sqlc.arg(workspace_id); +-- name: UnfavoriteWorkspace :exec +DELETE FROM favorite_workspaces WHERE user_id = sqlc.arg(user_id) AND workspace_id = sqlc.arg(workspace_id); diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 9e90b7da4c34c..c9fb2e8be63b7 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -12,6 +12,7 @@ const ( UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); UniqueDbcryptKeysPkey UniqueConstraint = "dbcrypt_keys_pkey" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_pkey PRIMARY KEY (number); UniqueDbcryptKeysRevokedKeyDigestKey UniqueConstraint = "dbcrypt_keys_revoked_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_revoked_key_digest_key UNIQUE (revoked_key_digest); + UniqueFavoriteWorkspacesUserIDWorkspaceIDKey UniqueConstraint = "favorite_workspaces_user_id_workspace_id_key" // ALTER TABLE ONLY favorite_workspaces ADD CONSTRAINT favorite_workspaces_user_id_workspace_id_key UNIQUE (user_id, workspace_id); UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); UniqueFilesPkey UniqueConstraint = "files_pkey" // ALTER TABLE ONLY files ADD CONSTRAINT files_pkey PRIMARY KEY (id); UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); @@ -47,7 +48,6 @@ const ( UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_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); - UniqueUserPinnedWorkspacesUserIDWorkspaceIDKey UniqueConstraint = "user_pinned_workspaces_user_id_workspace_id_key" // ALTER TABLE ONLY user_pinned_workspaces ADD CONSTRAINT user_pinned_workspaces_user_id_workspace_id_key UNIQUE (user_id, workspace_id); UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id); UniqueWorkspaceAgentMetadataPkey UniqueConstraint = "workspace_agent_metadata_pkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_pkey PRIMARY KEY (workspace_agent_id, key); diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 054918ee7cd6c..de3b5d4a022fb 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1021,21 +1021,21 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, code, resp) } -// @Summary Pin workspace by ID. -// @ID pin-workspace-by-id +// @Summary Favor workspace by ID. +// @ID favorite-workspace-by-id // @Security CoderSessionToken // @Accept json // @Tags Workspaces // @Param workspace path string true "Workspace ID" format(uuid) // @Success 204 -// @Router /workspaces/{workspace}/pin [put] -func (api *API) putWorkspacePin(rw http.ResponseWriter, r *http.Request) { +// @Router /workspaces/{workspace}/favorite [put] +func (api *API) putFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() apiKey = httpmw.APIKey(r) workspace = httpmw.WorkspaceParam(r) auditor = api.Auditor.Load() - aReq, commitAudit = audit.InitRequest[database.UserPinnedWorkspace](rw, &audit.RequestParams{ + aReq, commitAudit = audit.InitRequest[database.FavoriteWorkspace](rw, &audit.RequestParams{ Audit: *auditor, Log: api.Logger, Request: r, @@ -1043,9 +1043,9 @@ func (api *API) putWorkspacePin(rw http.ResponseWriter, r *http.Request) { }) ) defer commitAudit() - aReq.Old = database.UserPinnedWorkspace{} + aReq.Old = database.FavoriteWorkspace{} - err := api.Database.PinWorkspace(ctx, database.PinWorkspaceParams{ + err := api.Database.FavoriteWorkspace(ctx, database.FavoriteWorkspaceParams{ UserID: apiKey.UserID, WorkspaceID: workspace.ID, }) @@ -1057,7 +1057,7 @@ func (api *API) putWorkspacePin(rw http.ResponseWriter, r *http.Request) { return } - aReq.New = database.UserPinnedWorkspace{ + aReq.New = database.FavoriteWorkspace{ UserID: apiKey.UserID, WorkspaceID: workspace.ID, } @@ -1065,21 +1065,21 @@ func (api *API) putWorkspacePin(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } -// @Summary Unpin workspace by ID. -// @ID unpin-workspace-by-id +// @Summary Unfavor workspace by ID. +// @ID unfavorite-workspace-by-id // @Security CoderSessionToken // @Accept json // @Tags Workspaces // @Param workspace path string true "Workspace ID" format(uuid) // @Success 204 -// @Router /workspaces/{workspace}/pin [delete] -func (api *API) deleteWorkspacePin(rw http.ResponseWriter, r *http.Request) { +// @Router /workspaces/{workspace}/favorite [delete] +func (api *API) deleteFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() apiKey = httpmw.APIKey(r) workspace = httpmw.WorkspaceParam(r) auditor = api.Auditor.Load() - aReq, commitAudit = audit.InitRequest[database.UserPinnedWorkspace](rw, &audit.RequestParams{ + aReq, commitAudit = audit.InitRequest[database.FavoriteWorkspace](rw, &audit.RequestParams{ Audit: *auditor, Log: api.Logger, Request: r, @@ -1087,12 +1087,12 @@ func (api *API) deleteWorkspacePin(rw http.ResponseWriter, r *http.Request) { }) ) defer commitAudit() - aReq.Old = database.UserPinnedWorkspace{ + aReq.Old = database.FavoriteWorkspace{ UserID: apiKey.UserID, WorkspaceID: workspace.ID, } - err := api.Database.UnpinWorkspace(ctx, database.UnpinWorkspaceParams{ + err := api.Database.UnfavoriteWorkspace(ctx, database.UnfavoriteWorkspaceParams{ UserID: apiKey.UserID, WorkspaceID: workspace.ID, }) @@ -1103,7 +1103,7 @@ func (api *API) deleteWorkspacePin(rw http.ResponseWriter, r *http.Request) { }) return } - aReq.New = database.UserPinnedWorkspace{} + aReq.New = database.FavoriteWorkspace{} rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 23b9dec8828dc..e7240761f19d2 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2928,78 +2928,83 @@ func TestWorkspaceDormant(t *testing.T) { coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) }) - t.Run("PinUnpin", func(t *testing.T) { + t.Run("FavoriteUnfavorite", func(t *testing.T) { t.Parallel() // Given: var ( auditRecorder = audit.NewMock() - client = coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - Auditor: auditRecorder, + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + Auditor: auditRecorder, }) - owner = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template = coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - memberClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - workspace = coderdtest.CreateWorkspace(t, memberClient, owner.OrganizationID, template.ID) - _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + owner = coderdtest.CreateFirstUser(t, client) + memberClient, member = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + wsb1 = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do() + _ = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: owner.UserID, OrganizationID: owner.OrganizationID}).Do() ) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - // Initially, workspace should not be pinned. + // Initially, workspace should not be favored for member. workspaces, err := memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Pinned) - ws, err := memberClient.Workspace(ctx, workspace.ID) + require.False(t, workspaces.Workspaces[0].Favored) + ws, err := memberClient.Workspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + require.False(t, ws.Favored) + + // Also not for owner. + workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.False(t, ws.Pinned) + require.Len(t, workspaces.Workspaces, 2) + require.False(t, workspaces.Workspaces[0].Favored) + require.False(t, workspaces.Workspaces[1].Favored) // When member pins workspace - err = memberClient.PinWorkspace(ctx, workspace.ID) + err = memberClient.FavoriteWorkspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - // Then it should be pinned for them + // Then it should be favored for them workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) require.Len(t, workspaces.Workspaces, 1) - require.True(t, workspaces.Workspaces[0].Pinned) - ws, err = memberClient.Workspace(ctx, workspace.ID) + require.True(t, workspaces.Workspaces[0].Favored) + ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - require.True(t, ws.Pinned) + require.True(t, ws.Favored) // But not for someone else workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Pinned) - ws, err = client.Workspace(ctx, workspace.ID) + require.Len(t, workspaces.Workspaces, 2) + require.False(t, workspaces.Workspaces[0].Favored) + require.False(t, workspaces.Workspaces[1].Favored) + ws, err = client.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - require.False(t, ws.Pinned) + require.False(t, ws.Favored) // When member unpins workspace - err = memberClient.UnpinWorkspace(ctx, workspace.ID) + err = memberClient.UnfavoriteWorkspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - // Then it should no longer be pinned for them + // Then it should no longer be favored workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Pinned) - ws, err = memberClient.Workspace(ctx, workspace.ID) + require.False(t, workspaces.Workspaces[0].Favored) + ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - require.False(t, ws.Pinned) + require.False(t, ws.Favored) // Assert invariant: workspace should remain unpinned for a different user workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Pinned) - ws, err = client.Workspace(ctx, workspace.ID) + require.Len(t, workspaces.Workspaces, 2) + require.False(t, workspaces.Workspaces[0].Favored) + require.False(t, workspaces.Workspaces[1].Favored) + ws, err = client.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - require.False(t, ws.Pinned) + require.False(t, ws.Favored) }) } diff --git a/codersdk/audit.go b/codersdk/audit.go index c1ea077ec0831..636bc66e83cff 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -14,19 +14,20 @@ import ( type ResourceType string const ( - ResourceTypeTemplate ResourceType = "template" - ResourceTypeTemplateVersion ResourceType = "template_version" - ResourceTypeUser ResourceType = "user" - ResourceTypeWorkspace ResourceType = "workspace" - ResourceTypeWorkspaceBuild ResourceType = "workspace_build" - ResourceTypeGitSSHKey ResourceType = "git_ssh_key" - ResourceTypeAPIKey ResourceType = "api_key" - ResourceTypeGroup ResourceType = "group" - ResourceTypeLicense ResourceType = "license" - ResourceTypeConvertLogin ResourceType = "convert_login" - ResourceTypeHealthSettings ResourceType = "health_settings" - ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" - ResourceTypeOrganization ResourceType = "organization" + ResourceTypeTemplate ResourceType = "template" + ResourceTypeTemplateVersion ResourceType = "template_version" + ResourceTypeUser ResourceType = "user" + ResourceTypeWorkspace ResourceType = "workspace" + ResourceTypeWorkspaceBuild ResourceType = "workspace_build" + ResourceTypeFavoriteWorkspace ResourceType = "favorite_workspace" + ResourceTypeGitSSHKey ResourceType = "git_ssh_key" + ResourceTypeAPIKey ResourceType = "api_key" + ResourceTypeGroup ResourceType = "group" + ResourceTypeLicense ResourceType = "license" + ResourceTypeConvertLogin ResourceType = "convert_login" + ResourceTypeHealthSettings ResourceType = "health_settings" + ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" + ResourceTypeOrganization ResourceType = "organization" ) func (r ResourceType) FriendlyString() string { @@ -43,6 +44,8 @@ func (r ResourceType) FriendlyString() string { // workspace builds have a unique friendly string // see coderd/audit.go:298 for explanation return "workspace" + case ResourceTypeFavoriteWorkspace: + return "favorite_workspace" case ResourceTypeGitSSHKey: return "git ssh key" case ResourceTypeAPIKey: diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index ce5b81579f431..7a5cf48b933e0 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -58,7 +58,7 @@ type Workspace struct { Health WorkspaceHealth `json:"health"` AutomaticUpdates AutomaticUpdates `json:"automatic_updates" enums:"always,never"` AllowRenames bool `json:"allow_renames"` - Pinned bool `json:"pinned"` + Favored bool `json:"favored"` } func (w Workspace) FullName() string { @@ -472,8 +472,8 @@ func (c *Client) ResolveAutostart(ctx context.Context, workspaceID string) (Reso return response, json.NewDecoder(res.Body).Decode(&response) } -func (c *Client) PinWorkspace(ctx context.Context, workspaceID uuid.UUID) error { - res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/workspaces/%s/pin", workspaceID), nil) +func (c *Client) FavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/workspaces/%s/favorite", workspaceID), nil) if err != nil { return err } @@ -484,8 +484,8 @@ func (c *Client) PinWorkspace(ctx context.Context, workspaceID uuid.UUID) error return nil } -func (c *Client) UnpinWorkspace(ctx context.Context, workspaceID uuid.UUID) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/workspaces/%s/pin", workspaceID), nil) +func (c *Client) UnfavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/workspaces/%s/favorite", workspaceID), nil) if err != nil { return err } diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 3ec2e2ede886d..6e224b823b344 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4369,21 +4369,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value | -| ------------------ | -| `template` | -| `template_version` | -| `user` | -| `workspace` | -| `workspace_build` | -| `git_ssh_key` | -| `api_key` | -| `group` | -| `license` | -| `convert_login` | -| `health_settings` | -| `workspace_proxy` | -| `organization` | +| Value | +| -------------------- | +| `template` | +| `template_version` | +| `user` | +| `workspace` | +| `workspace_build` | +| `favorite_workspace` | +| `git_ssh_key` | +| `api_key` | +| `group` | +| `license` | +| `convert_login` | +| `health_settings` | +| `workspace_proxy` | +| `organization` | ## codersdk.Response @@ -6079,6 +6080,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -6110,6 +6112,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `outdated` | boolean | false | | | | `owner_id` | string | false | | | | `owner_name` | string | false | | | +| `pinned` | boolean | false | | | | `template_active_version_id` | string | false | | | | `template_allow_user_cancel_workspace_jobs` | boolean | false | | | | `template_display_name` | string | false | | | @@ -7338,6 +7341,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 2cd3f71700a27..3091358ff3a83 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -211,6 +211,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -422,6 +423,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -632,6 +634,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -844,6 +847,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -1171,6 +1175,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -1245,17 +1250,17 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/extend \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Pin workspace by ID. +## Favor workspace by ID. ### Code samples ```shell # Example request using curl -curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/pin \ +curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/favorite \ -H 'Coder-Session-Token: API_KEY' ``` -`PUT /workspaces/{workspace}/pin` +`PUT /workspaces/{workspace}/favorite` ### Parameters @@ -1271,17 +1276,17 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/pin \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Unpin workspace by ID. +## Unfavor workspace by ID. ### Code samples ```shell # Example request using curl -curl -X DELETE http://coder-server:8080/api/v2/workspaces/{workspace}/pin \ +curl -X DELETE http://coder-server:8080/api/v2/workspaces/{workspace}/favorite \ -H 'Coder-Session-Token: API_KEY' ``` -`DELETE /workspaces/{workspace}/pin` +`DELETE /workspaces/{workspace}/favorite` ### Parameters diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 48d05be9d9e73..1a8e0b586002a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1490,6 +1490,7 @@ export interface Workspace { readonly health: WorkspaceHealth; readonly automatic_updates: AutomaticUpdates; readonly allow_renames: boolean; + readonly pinned: boolean; } // From codersdk/workspaceagents.go @@ -2031,6 +2032,7 @@ export const RBACResources: RBACResource[] = [ export type ResourceType = | "api_key" | "convert_login" + | "favorite_workspace" | "git_ssh_key" | "group" | "health_settings" @@ -2045,6 +2047,7 @@ export type ResourceType = export const ResourceTypes: ResourceType[] = [ "api_key", "convert_login", + "favorite_workspace", "git_ssh_key", "group", "health_settings", From a814b65843c89f4995fc961d45af8ae01543b17d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 11:20:20 +0000 Subject: [PATCH 08/27] move to top-level test --- coderd/workspaces_test.go | 140 +++++++++++++++++++------------------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index e7240761f19d2..85b7aaab94e73 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2927,84 +2927,84 @@ func TestWorkspaceDormant(t *testing.T) { require.NoError(t, err) coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) }) +} - t.Run("FavoriteUnfavorite", func(t *testing.T) { - t.Parallel() - // Given: - var ( - auditRecorder = audit.NewMock() - client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ - Auditor: auditRecorder, - }) - owner = coderdtest.CreateFirstUser(t, client) - memberClient, member = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - wsb1 = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do() - _ = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: owner.UserID, OrganizationID: owner.OrganizationID}).Do() - ) +func TestWorkspaceFavoriteUnfavorite(t *testing.T) { + t.Parallel() + // Given: + var ( + auditRecorder = audit.NewMock() + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + Auditor: auditRecorder, + }) + owner = coderdtest.CreateFirstUser(t, client) + memberClient, member = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + wsb1 = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do() + _ = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: owner.UserID, OrganizationID: owner.OrganizationID}).Do() + ) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() - // Initially, workspace should not be favored for member. - workspaces, err := memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Favored) - ws, err := memberClient.Workspace(ctx, wsb1.Workspace.ID) - require.NoError(t, err) - require.False(t, ws.Favored) + // Initially, workspace should not be favored for member. + workspaces, err := memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 1) + require.False(t, workspaces.Workspaces[0].Favored) + ws, err := memberClient.Workspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + require.False(t, ws.Favored) - // Also not for owner. - workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 2) - require.False(t, workspaces.Workspaces[0].Favored) - require.False(t, workspaces.Workspaces[1].Favored) + // Also not for owner. + workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 2) + require.False(t, workspaces.Workspaces[0].Favored) + require.False(t, workspaces.Workspaces[1].Favored) - // When member pins workspace - err = memberClient.FavoriteWorkspace(ctx, wsb1.Workspace.ID) - require.NoError(t, err) + // When member pins workspace + err = memberClient.FavoriteWorkspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) - // Then it should be favored for them - workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 1) - require.True(t, workspaces.Workspaces[0].Favored) - ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) - require.NoError(t, err) - require.True(t, ws.Favored) + // Then it should be favored for them + workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 1) + require.True(t, workspaces.Workspaces[0].Favored) + ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + require.True(t, ws.Favored) - // But not for someone else - workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 2) - require.False(t, workspaces.Workspaces[0].Favored) - require.False(t, workspaces.Workspaces[1].Favored) - ws, err = client.Workspace(ctx, wsb1.Workspace.ID) - require.NoError(t, err) - require.False(t, ws.Favored) + // But not for someone else + workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 2) + require.False(t, workspaces.Workspaces[0].Favored) + require.False(t, workspaces.Workspaces[1].Favored) + ws, err = client.Workspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + require.False(t, ws.Favored) - // When member unpins workspace - err = memberClient.UnfavoriteWorkspace(ctx, wsb1.Workspace.ID) - require.NoError(t, err) + // When member unpins workspace + err = memberClient.UnfavoriteWorkspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) - // Then it should no longer be favored - workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Favored) - ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) - require.NoError(t, err) - require.False(t, ws.Favored) + // Then it should no longer be favored + workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 1) + require.False(t, workspaces.Workspaces[0].Favored) + ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + require.False(t, ws.Favored) - // Assert invariant: workspace should remain unpinned for a different user - workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 2) - require.False(t, workspaces.Workspaces[0].Favored) - require.False(t, workspaces.Workspaces[1].Favored) - ws, err = client.Workspace(ctx, wsb1.Workspace.ID) - require.NoError(t, err) - require.False(t, ws.Favored) - }) + // Assert invariant: workspace should remain unpinned for a different user + workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 2) + require.False(t, workspaces.Workspaces[0].Favored) + require.False(t, workspaces.Workspaces[1].Favored) + ws, err = client.Workspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + require.False(t, ws.Favored) } From 89d618df8641237efaefe60f7dded5e44e7f4765 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 12:07:40 +0000 Subject: [PATCH 09/27] s/favored/favorite/g --- coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 6 +- coderd/database/dbauthz/dbauthz.go | 28 ++++----- coderd/database/dbmem/dbmem.go | 82 +++++++++++++------------- coderd/database/modelqueries.go | 2 +- coderd/database/queries.sql.go | 8 +-- coderd/database/queries/workspaces.sql | 4 +- coderd/workspaces_test.go | 28 ++++----- codersdk/workspaces.go | 2 +- docs/api/schemas.md | 6 +- docs/api/workspaces.md | 10 ++-- site/src/api/typesGenerated.ts | 2 +- site/src/testHelpers/entities.ts | 1 + 13 files changed, 93 insertions(+), 92 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ae9b59e59a556..7e95d2ad04ee2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11990,6 +11990,9 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, + "favorite": { + "type": "boolean" + }, "health": { "description": "Health shows the health of the workspace and information about\nwhat is causing an unhealthy status.", "allOf": [ @@ -12026,9 +12029,6 @@ const docTemplate = `{ "owner_name": { "type": "string" }, - "pinned": { - "type": "boolean" - }, "template_active_version_id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 764794188152b..741f471a4a07f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10866,6 +10866,9 @@ "type": "string", "format": "date-time" }, + "favorite": { + "type": "boolean" + }, "health": { "description": "Health shows the health of the workspace and information about\nwhat is causing an unhealthy status.", "allOf": [ @@ -10902,9 +10905,6 @@ "owner_name": { "type": "string" }, - "pinned": { - "type": "boolean" - }, "template_active_version_id": { "type": "string", "format": "uuid" diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 15ceb8cb06a5f..bc0b4d4741387 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -656,20 +656,6 @@ func authorizedTemplateVersionFromJob(ctx context.Context, q *querier, job datab } } -func (q *querier) FavoriteWorkspace(ctx context.Context, arg database.FavoriteWorkspaceParams) error { - if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { - return err - } - return q.db.FavoriteWorkspace(ctx, arg) -} - -func (q *querier) UnfavoriteWorkspace(ctx context.Context, arg database.UnfavoriteWorkspaceParams) error { - if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { - return err - } - return q.db.UnfavoriteWorkspace(ctx, arg) -} - func (q *querier) AcquireLock(ctx context.Context, id int64) error { return q.db.AcquireLock(ctx, id) } @@ -905,6 +891,13 @@ func (q *querier) DeleteTailnetTunnel(ctx context.Context, arg database.DeleteTa return q.db.DeleteTailnetTunnel(ctx, arg) } +func (q *querier) FavoriteWorkspace(ctx context.Context, arg database.FavoriteWorkspaceParams) error { + if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { + return err + } + return q.db.FavoriteWorkspace(ctx, arg) +} + func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id) } @@ -2523,6 +2516,13 @@ func (q *querier) UnarchiveTemplateVersion(ctx context.Context, arg database.Una return q.db.UnarchiveTemplateVersion(ctx, arg) } +func (q *querier) UnfavoriteWorkspace(ctx context.Context, arg database.UnfavoriteWorkspaceParams) error { + if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { + return err + } + return q.db.UnfavoriteWorkspace(ctx, arg) +} + func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error { fetch := func(ctx context.Context, arg database.UpdateAPIKeyByIDParams) (database.APIKey, error) { return q.db.GetAPIKeyByID(ctx, arg.ID) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 48667b8c474f1..25a8dfe573ebe 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -732,47 +732,6 @@ func isNotNull(v interface{}) bool { return reflect.ValueOf(v).FieldByName("Valid").Bool() } -func (q *FakeQuerier) FavoriteWorkspace(_ context.Context, arg database.FavoriteWorkspaceParams) error { - err := validateDatabaseType(arg) - if err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - for _, upw := range q.favoriteWorkspaces { - if arg.UserID == upw.UserID && arg.WorkspaceID == upw.WorkspaceID { - return errDuplicateKey - } - } - return nil -} - -func (q *FakeQuerier) UnfavoriteWorkspace(_ context.Context, arg database.UnfavoriteWorkspaceParams) error { - err := validateDatabaseType(arg) - if err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - for index, upw := range q.favoriteWorkspaces { - if upw.UserID != arg.UserID { - continue - } - if upw.WorkspaceID != arg.WorkspaceID { - continue - } - q.favoriteWorkspaces[index] = q.favoriteWorkspaces[len(q.apiKeys)-1] - q.favoriteWorkspaces = q.favoriteWorkspaces[:len(q.favoriteWorkspaces)-1] - return nil - } - - return nil -} - func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } @@ -1358,6 +1317,23 @@ func (*FakeQuerier) DeleteTailnetTunnel(_ context.Context, arg database.DeleteTa return database.DeleteTailnetTunnelRow{}, ErrUnimplemented } +func (q *FakeQuerier) FavoriteWorkspace(_ context.Context, arg database.FavoriteWorkspaceParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, upw := range q.favoriteWorkspaces { + if arg.UserID == upw.UserID && arg.WorkspaceID == upw.WorkspaceID { + return errDuplicateKey + } + } + return nil +} + func (q *FakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIKey, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -6027,6 +6003,30 @@ func (q *FakeQuerier) UnarchiveTemplateVersion(_ context.Context, arg database.U return sql.ErrNoRows } +func (q *FakeQuerier) UnfavoriteWorkspace(_ context.Context, arg database.UnfavoriteWorkspaceParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, upw := range q.favoriteWorkspaces { + if upw.UserID != arg.UserID { + continue + } + if upw.WorkspaceID != arg.WorkspaceID { + continue + } + q.favoriteWorkspaces[index] = q.favoriteWorkspaces[len(q.apiKeys)-1] + q.favoriteWorkspaces = q.favoriteWorkspaces[:len(q.favoriteWorkspaces)-1] + return nil + } + + return nil +} + func (q *FakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPIKeyByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 7e1700a465a9f..0e251b86b8113 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -253,7 +253,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, - &i.Favored, + &i.Favorite, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ad460f2afee3c..d51e524dce433 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11184,7 +11184,7 @@ SELECT COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, - (fws.user_id IS NOT NULL)::boolean AS favored, + (fws.user_id IS NOT NULL)::boolean AS favorite, COUNT(*) OVER () as count FROM workspaces @@ -11378,7 +11378,7 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY - favored DESC, + favorite DESC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND @@ -11429,7 +11429,7 @@ type GetWorkspacesRow struct { TemplateName string `db:"template_name" json:"template_name"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` - Favored bool `db:"favored" json:"favored"` + Favorite bool `db:"favorite" json:"favorite"` Count int64 `db:"count" json:"count"` } @@ -11475,7 +11475,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, - &i.Favored, + &i.Favorite, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index bbb9bb96c5dce..b97a1b3f0b872 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -82,7 +82,7 @@ SELECT COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, - (fws.user_id IS NOT NULL)::boolean AS favored, + (fws.user_id IS NOT NULL)::boolean AS favorite, COUNT(*) OVER () as count FROM workspaces @@ -276,7 +276,7 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY - favored DESC, + favorite DESC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 85b7aaab94e73..0950443c14e99 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2950,17 +2950,17 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { workspaces, err := memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Favored) + require.False(t, workspaces.Workspaces[0].Favorite) ws, err := memberClient.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - require.False(t, ws.Favored) + require.False(t, ws.Favorite) // Also not for owner. workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) require.Len(t, workspaces.Workspaces, 2) - require.False(t, workspaces.Workspaces[0].Favored) - require.False(t, workspaces.Workspaces[1].Favored) + require.False(t, workspaces.Workspaces[0].Favorite) + require.False(t, workspaces.Workspaces[1].Favorite) // When member pins workspace err = memberClient.FavoriteWorkspace(ctx, wsb1.Workspace.ID) @@ -2970,20 +2970,20 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) require.Len(t, workspaces.Workspaces, 1) - require.True(t, workspaces.Workspaces[0].Favored) + require.True(t, workspaces.Workspaces[0].Favorite) ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - require.True(t, ws.Favored) + require.True(t, ws.Favorite) // But not for someone else workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) require.Len(t, workspaces.Workspaces, 2) - require.False(t, workspaces.Workspaces[0].Favored) - require.False(t, workspaces.Workspaces[1].Favored) + require.False(t, workspaces.Workspaces[0].Favorite) + require.False(t, workspaces.Workspaces[1].Favorite) ws, err = client.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - require.False(t, ws.Favored) + require.False(t, ws.Favorite) // When member unpins workspace err = memberClient.UnfavoriteWorkspace(ctx, wsb1.Workspace.ID) @@ -2993,18 +2993,18 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Favored) + require.False(t, workspaces.Workspaces[0].Favorite) ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - require.False(t, ws.Favored) + require.False(t, ws.Favorite) // Assert invariant: workspace should remain unpinned for a different user workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) require.Len(t, workspaces.Workspaces, 2) - require.False(t, workspaces.Workspaces[0].Favored) - require.False(t, workspaces.Workspaces[1].Favored) + require.False(t, workspaces.Workspaces[0].Favorite) + require.False(t, workspaces.Workspaces[1].Favorite) ws, err = client.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - require.False(t, ws.Favored) + require.False(t, ws.Favorite) } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 7a5cf48b933e0..cef8a8bae2a91 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -58,7 +58,7 @@ type Workspace struct { Health WorkspaceHealth `json:"health"` AutomaticUpdates AutomaticUpdates `json:"automatic_updates" enums:"always,never"` AllowRenames bool `json:"allow_renames"` - Favored bool `json:"favored"` + Favorite bool `json:"favorite"` } func (w Workspace) FullName() string { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 6e224b823b344..d1777194b91b1 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -5922,6 +5922,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", "dormant_at": "2019-08-24T14:15:22Z", + "favorite": true, "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -6080,7 +6081,6 @@ If the schedule is empty, the user will be updated to use the default schedule.| "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", - "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -6103,6 +6103,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `created_at` | string | false | | | | `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. | | `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time*til* field on its template. | +| `favorite` | boolean | false | | | | `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | | `id` | string | false | | | | `last_used_at` | string | false | | | @@ -6112,7 +6113,6 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `outdated` | boolean | false | | | | `owner_id` | string | false | | | | `owner_name` | string | false | | | -| `pinned` | boolean | false | | | | `template_active_version_id` | string | false | | | | `template_allow_user_cancel_workspace_jobs` | boolean | false | | | | `template_display_name` | string | false | | | @@ -7187,6 +7187,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", "dormant_at": "2019-08-24T14:15:22Z", + "favorite": true, "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -7341,7 +7342,6 @@ If the schedule is empty, the user will be updated to use the default schedule.| "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", - "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 3091358ff3a83..c3304ac5172e1 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -53,6 +53,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", "dormant_at": "2019-08-24T14:15:22Z", + "favorite": true, "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -211,7 +212,6 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", - "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -265,6 +265,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", "dormant_at": "2019-08-24T14:15:22Z", + "favorite": true, "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -423,7 +424,6 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", - "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -480,6 +480,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", "dormant_at": "2019-08-24T14:15:22Z", + "favorite": true, "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -634,7 +635,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", - "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -689,6 +689,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", "dormant_at": "2019-08-24T14:15:22Z", + "favorite": true, "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -847,7 +848,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", - "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -1017,6 +1017,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", "dormant_at": "2019-08-24T14:15:22Z", + "favorite": true, "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -1175,7 +1176,6 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", - "pinned": true, "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1a8e0b586002a..6e712b8e419e3 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1490,7 +1490,7 @@ export interface Workspace { readonly health: WorkspaceHealth; readonly automatic_updates: AutomaticUpdates; readonly allow_renames: boolean; - readonly pinned: boolean; + readonly favorite: boolean; } // From codersdk/workspaceagents.go diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 59d60fda1aad5..b3ba287be5060 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1020,6 +1020,7 @@ export const MockWorkspace: TypesGen.Workspace = { }, automatic_updates: "never", allow_renames: true, + favorite: true, }; export const MockStoppedWorkspace: TypesGen.Workspace = { From 7238b95c0cbb6c18a6f2b7b18a7c28b3b4276158 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 13:18:18 +0000 Subject: [PATCH 10/27] move favorite status to workspaces table to avoid sqlc join sadness --- coderd/audit.go | 5 - coderd/audit/diff.go | 1 - coderd/audit/request.go | 6 -- coderd/database/dbauthz/dbauthz.go | 16 +-- coderd/database/dbauthz/dbauthz_test.go | 10 +- coderd/database/dbmem/dbmem.go | 25 +++-- coderd/database/dbmetrics/dbmetrics.go | 4 +- coderd/database/dbmock/dbmock.go | 4 +- coderd/database/dump.sql | 16 +-- .../000186_user_favorite_workspaces.down.sql | 2 +- .../000186_user_favorite_workspaces.up.sql | 10 +- coderd/database/modelqueries.go | 4 +- coderd/database/models.go | 38 +++----- coderd/database/querier.go | 4 +- coderd/database/queries.sql.go | 97 ++++++++----------- coderd/database/queries/workspaces.sql | 19 +--- coderd/database/unique_constraint.go | 1 - coderd/workspaces.go | 37 +++---- coderd/workspaces_test.go | 6 +- docs/admin/audit-logs.md | 2 +- enterprise/audit/table.go | 1 + 21 files changed, 114 insertions(+), 194 deletions(-) diff --git a/coderd/audit.go b/coderd/audit.go index 3cf6e7a5628fe..d6b20a292916c 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -271,11 +271,6 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string { return str } - // "Pinning" (favoriting) a workspace is a separate thing. - if alog.ResourceType == database.ResourceTypeFavoriteWorkspace { - return fmt.Sprintf("{user} pinned workspace %s", alog.ResourceTarget) - } - str += fmt.Sprintf(" %s", codersdk.ResourceType(alog.ResourceType).FriendlyString()) diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 632a0acf5fb69..bdaef00bb082b 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -13,7 +13,6 @@ type Auditable interface { database.TemplateVersion | database.User | database.Workspace | - database.FavoriteWorkspace | database.GitSSHKey | database.WorkspaceBuild | database.AuditableGroup | diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 1aba5a3e17d58..c61db9ef83914 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -95,8 +95,6 @@ func ResourceTarget[T Auditable](tgt T) string { return string(typed.ToLoginType) case database.HealthSettings: return "" // no target? - case database.FavoriteWorkspace: - return typed.WorkspaceID.String() default: panic(fmt.Sprintf("unknown resource %T", tgt)) } @@ -130,8 +128,6 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { case database.HealthSettings: // Artificial ID for auditing purposes return typed.ID - case database.FavoriteWorkspace: - return typed.WorkspaceID default: panic(fmt.Sprintf("unknown resource %T", tgt)) } @@ -163,8 +159,6 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeConvertLogin case database.HealthSettings: return database.ResourceTypeHealthSettings - case database.FavoriteWorkspace: - return database.ResourceTypeFavoriteWorkspace default: panic(fmt.Sprintf("unknown resource %T", typed)) } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index bc0b4d4741387..97743186f1356 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -891,11 +891,11 @@ func (q *querier) DeleteTailnetTunnel(ctx context.Context, arg database.DeleteTa return q.db.DeleteTailnetTunnel(ctx, arg) } -func (q *querier) FavoriteWorkspace(ctx context.Context, arg database.FavoriteWorkspaceParams) error { - if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { - return err +func (q *querier) FavoriteWorkspace(ctx context.Context, id uuid.UUID) error { + fetch := func(ctx context.Context, id uuid.UUID) (database.Workspace, error) { + return q.db.GetWorkspaceByID(ctx, id) } - return q.db.FavoriteWorkspace(ctx, arg) + return update(q.log, q.auth, fetch, q.db.FavoriteWorkspace)(ctx, id) } func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { @@ -2516,11 +2516,11 @@ func (q *querier) UnarchiveTemplateVersion(ctx context.Context, arg database.Una return q.db.UnarchiveTemplateVersion(ctx, arg) } -func (q *querier) UnfavoriteWorkspace(ctx context.Context, arg database.UnfavoriteWorkspaceParams) error { - if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { - return err +func (q *querier) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error { + fetch := func(ctx context.Context, id uuid.UUID) (database.Workspace, error) { + return q.db.GetWorkspaceByID(ctx, id) } - return q.db.UnfavoriteWorkspace(ctx, arg) + return update(q.log, q.auth, fetch, q.db.UnfavoriteWorkspace)(ctx, id) } func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index d81a857a4c3a8..9668497dbfbae 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1581,18 +1581,12 @@ func (s *MethodTestSuite) TestWorkspace() { s.Run("FavoriteWorkspace", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID}) - check.Args(database.FavoriteWorkspaceParams{ - UserID: u.ID, - WorkspaceID: ws.ID, - }).Asserts(ws, rbac.ActionRead).Returns() + check.Args(ws.ID).Asserts(ws, rbac.ActionUpdate).Returns() })) s.Run("UnfavoriteWorkspace", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID}) - check.Args(database.UnfavoriteWorkspaceParams{ - UserID: u.ID, - WorkspaceID: ws.ID, - }).Asserts(ws, rbac.ActionRead).Returns() + check.Args(ws.ID).Asserts(ws, rbac.ActionUpdate).Returns() })) } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 25a8dfe573ebe..d3e0d07233efa 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -71,7 +71,6 @@ func New() database.Store { workspaceBuilds: make([]database.WorkspaceBuildTable, 0), workspaceApps: make([]database.WorkspaceApp, 0), workspaces: make([]database.Workspace, 0), - favoriteWorkspaces: make([]database.FavoriteWorkspace, 0), licenses: make([]database.License, 0), workspaceProxies: make([]database.WorkspaceProxy, 0), locks: map[int64]struct{}{}, @@ -155,7 +154,6 @@ type data struct { workspaceResourceMetadata []database.WorkspaceResourceMetadatum workspaceResources []database.WorkspaceResource workspaces []database.Workspace - favoriteWorkspaces []database.FavoriteWorkspace workspaceProxies []database.WorkspaceProxy // Locks is a map of lock names. Any keys within the map are currently // locked. @@ -1317,7 +1315,7 @@ func (*FakeQuerier) DeleteTailnetTunnel(_ context.Context, arg database.DeleteTa return database.DeleteTailnetTunnelRow{}, ErrUnimplemented } -func (q *FakeQuerier) FavoriteWorkspace(_ context.Context, arg database.FavoriteWorkspaceParams) error { +func (q *FakeQuerier) FavoriteWorkspace(_ context.Context, arg uuid.UUID) error { err := validateDatabaseType(arg) if err != nil { return err @@ -1326,10 +1324,13 @@ func (q *FakeQuerier) FavoriteWorkspace(_ context.Context, arg database.Favorite q.mutex.Lock() defer q.mutex.Unlock() - for _, upw := range q.favoriteWorkspaces { - if arg.UserID == upw.UserID && arg.WorkspaceID == upw.WorkspaceID { - return errDuplicateKey + for i := 0; i < len(q.workspaces); i++ { + if q.workspaces[i].ID != arg { + continue } + q.workspaces[i].FavoriteOf.Valid = true + q.workspaces[i].FavoriteOf.UUID = q.workspaces[i].OwnerID + return nil } return nil } @@ -6003,7 +6004,7 @@ func (q *FakeQuerier) UnarchiveTemplateVersion(_ context.Context, arg database.U return sql.ErrNoRows } -func (q *FakeQuerier) UnfavoriteWorkspace(_ context.Context, arg database.UnfavoriteWorkspaceParams) error { +func (q *FakeQuerier) UnfavoriteWorkspace(_ context.Context, arg uuid.UUID) error { err := validateDatabaseType(arg) if err != nil { return err @@ -6012,15 +6013,11 @@ func (q *FakeQuerier) UnfavoriteWorkspace(_ context.Context, arg database.Unfavo q.mutex.Lock() defer q.mutex.Unlock() - for index, upw := range q.favoriteWorkspaces { - if upw.UserID != arg.UserID { - continue - } - if upw.WorkspaceID != arg.WorkspaceID { + for i := 0; i < len(q.workspaces); i++ { + if q.workspaces[i].ID != arg { continue } - q.favoriteWorkspaces[index] = q.favoriteWorkspaces[len(q.apiKeys)-1] - q.favoriteWorkspaces = q.favoriteWorkspaces[:len(q.favoriteWorkspaces)-1] + q.workspaces[i].FavoriteOf = uuid.NullUUID{} return nil } diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 2cda3d6e29931..15c7492fb9b61 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -300,7 +300,7 @@ func (m metricsStore) DeleteTailnetTunnel(ctx context.Context, arg database.Dele return r0, r1 } -func (m metricsStore) FavoriteWorkspace(ctx context.Context, arg database.FavoriteWorkspaceParams) error { +func (m metricsStore) FavoriteWorkspace(ctx context.Context, arg uuid.UUID) error { start := time.Now() r0 := m.s.FavoriteWorkspace(ctx, arg) m.queryLatencies.WithLabelValues("FavoriteWorkspace").Observe(time.Since(start).Seconds()) @@ -1621,7 +1621,7 @@ func (m metricsStore) UnarchiveTemplateVersion(ctx context.Context, arg database return r0 } -func (m metricsStore) UnfavoriteWorkspace(ctx context.Context, arg database.UnfavoriteWorkspaceParams) error { +func (m metricsStore) UnfavoriteWorkspace(ctx context.Context, arg uuid.UUID) error { start := time.Now() r0 := m.s.UnfavoriteWorkspace(ctx, arg) m.queryLatencies.WithLabelValues("UnfavoriteWorkspace").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 57a8b3e556112..2b1c864b5adbf 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -501,7 +501,7 @@ func (mr *MockStoreMockRecorder) DeleteTailnetTunnel(arg0, arg1 any) *gomock.Cal } // FavoriteWorkspace mocks base method. -func (m *MockStore) FavoriteWorkspace(arg0 context.Context, arg1 database.FavoriteWorkspaceParams) error { +func (m *MockStore) FavoriteWorkspace(arg0 context.Context, arg1 uuid.UUID) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "FavoriteWorkspace", arg0, arg1) ret0, _ := ret[0].(error) @@ -3425,7 +3425,7 @@ func (mr *MockStoreMockRecorder) UnarchiveTemplateVersion(arg0, arg1 any) *gomoc } // UnfavoriteWorkspace mocks base method. -func (m *MockStore) UnfavoriteWorkspace(arg0 context.Context, arg1 database.UnfavoriteWorkspaceParams) error { +func (m *MockStore) UnfavoriteWorkspace(arg0 context.Context, arg1 uuid.UUID) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UnfavoriteWorkspace", arg0, arg1) ret0, _ := ret[0].(error) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 3b71841d5726c..de3fb42b29f23 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -134,8 +134,7 @@ CREATE TYPE resource_type AS ENUM ( 'license', 'workspace_proxy', 'convert_login', - 'health_settings', - 'favorite_workspace' + 'health_settings' ); CREATE TYPE startup_script_behavior AS ENUM ( @@ -403,11 +402,6 @@ COMMENT ON COLUMN external_auth_links.oauth_access_token_key_id IS 'The ID of th COMMENT ON COLUMN external_auth_links.oauth_refresh_token_key_id IS 'The ID of the key used to encrypt the OAuth refresh token. If this is NULL, the refresh token is not encrypted'; -CREATE TABLE favorite_workspaces ( - user_id uuid NOT NULL, - workspace_id uuid NOT NULL -); - CREATE TABLE files ( hash character varying(64) NOT NULL, created_at timestamp with time zone NOT NULL, @@ -1241,9 +1235,12 @@ CREATE TABLE workspaces ( last_used_at timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, dormant_at timestamp with time zone, deleting_at timestamp with time zone, - automatic_updates automatic_updates DEFAULT 'never'::automatic_updates NOT NULL + automatic_updates automatic_updates DEFAULT 'never'::automatic_updates NOT NULL, + favorite_of uuid ); +COMMENT ON COLUMN workspaces.favorite_of IS 'FavoriteOf contains the UUID of the workspace owner if the workspace has been favorited.'; + ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq'::regclass); ALTER TABLE ONLY provisioner_job_logs ALTER COLUMN id SET DEFAULT nextval('provisioner_job_logs_id_seq'::regclass); @@ -1274,9 +1271,6 @@ ALTER TABLE ONLY dbcrypt_keys ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_revoked_key_digest_key UNIQUE (revoked_key_digest); -ALTER TABLE ONLY favorite_workspaces - ADD CONSTRAINT favorite_workspaces_user_id_workspace_id_key UNIQUE (user_id, workspace_id); - ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); diff --git a/coderd/database/migrations/000186_user_favorite_workspaces.down.sql b/coderd/database/migrations/000186_user_favorite_workspaces.down.sql index da48786b4417c..ca0552b26e02d 100644 --- a/coderd/database/migrations/000186_user_favorite_workspaces.down.sql +++ b/coderd/database/migrations/000186_user_favorite_workspaces.down.sql @@ -1 +1 @@ -DROP TABLE favorite_workspaces; +ALTER TABLE ONLY workspaces DROP COLUMN favorite_of; diff --git a/coderd/database/migrations/000186_user_favorite_workspaces.up.sql b/coderd/database/migrations/000186_user_favorite_workspaces.up.sql index b62999a44e581..1dd2e14549667 100644 --- a/coderd/database/migrations/000186_user_favorite_workspaces.up.sql +++ b/coderd/database/migrations/000186_user_favorite_workspaces.up.sql @@ -1,7 +1,3 @@ -CREATE TABLE favorite_workspaces ( - user_id uuid NOT NULL, - workspace_id uuid NOT NULL, - UNIQUE(user_id, workspace_id) -); - -ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'favorite_workspace'; +ALTER TABLE ONLY workspaces +ADD COLUMN favorite_of uuid DEFAULT NULL; +COMMENT ON COLUMN workspaces.favorite_of IS 'FavoriteOf contains the UUID of the workspace owner if the workspace has been favorited.'; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 0e251b86b8113..cd9572857a07b 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -213,9 +213,9 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa // The name comment is for metric tracking query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s", filtered) rows, err := q.db.QueryContext(ctx, query, - arg.OwnerID, arg.Deleted, arg.Status, + arg.OwnerID, arg.OwnerUsername, arg.TemplateName, pq.Array(arg.TemplateIDs), @@ -250,10 +250,10 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.FavoriteOf, &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, - &i.Favorite, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/models.go b/coderd/database/models.go index 2ce424c4d800e..44acddadfbc74 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1146,20 +1146,19 @@ func AllProvisionerTypeValues() []ProvisionerType { type ResourceType string const ( - ResourceTypeOrganization ResourceType = "organization" - ResourceTypeTemplate ResourceType = "template" - ResourceTypeTemplateVersion ResourceType = "template_version" - ResourceTypeUser ResourceType = "user" - ResourceTypeWorkspace ResourceType = "workspace" - ResourceTypeGitSshKey ResourceType = "git_ssh_key" - ResourceTypeApiKey ResourceType = "api_key" - ResourceTypeGroup ResourceType = "group" - ResourceTypeWorkspaceBuild ResourceType = "workspace_build" - ResourceTypeLicense ResourceType = "license" - ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" - ResourceTypeConvertLogin ResourceType = "convert_login" - ResourceTypeHealthSettings ResourceType = "health_settings" - ResourceTypeFavoriteWorkspace ResourceType = "favorite_workspace" + ResourceTypeOrganization ResourceType = "organization" + ResourceTypeTemplate ResourceType = "template" + ResourceTypeTemplateVersion ResourceType = "template_version" + ResourceTypeUser ResourceType = "user" + ResourceTypeWorkspace ResourceType = "workspace" + ResourceTypeGitSshKey ResourceType = "git_ssh_key" + ResourceTypeApiKey ResourceType = "api_key" + ResourceTypeGroup ResourceType = "group" + ResourceTypeWorkspaceBuild ResourceType = "workspace_build" + ResourceTypeLicense ResourceType = "license" + ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" + ResourceTypeConvertLogin ResourceType = "convert_login" + ResourceTypeHealthSettings ResourceType = "health_settings" ) func (e *ResourceType) Scan(src interface{}) error { @@ -1211,8 +1210,7 @@ func (e ResourceType) Valid() bool { ResourceTypeLicense, ResourceTypeWorkspaceProxy, ResourceTypeConvertLogin, - ResourceTypeHealthSettings, - ResourceTypeFavoriteWorkspace: + ResourceTypeHealthSettings: return true } return false @@ -1233,7 +1231,6 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeWorkspaceProxy, ResourceTypeConvertLogin, ResourceTypeHealthSettings, - ResourceTypeFavoriteWorkspace, } } @@ -1748,11 +1745,6 @@ type ExternalAuthLink struct { OAuthExtra pqtype.NullRawMessage `db:"oauth_extra" json:"oauth_extra"` } -type FavoriteWorkspace struct { - UserID uuid.UUID `db:"user_id" json:"user_id"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` -} - type File struct { Hash string `db:"hash" json:"hash"` CreatedAt time.Time `db:"created_at" json:"created_at"` @@ -2193,6 +2185,8 @@ type Workspace struct { DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` + // FavoriteOf contains the UUID of the workspace owner if the workspace has been favorited. + FavoriteOf uuid.NullUUID `db:"favorite_of" json:"favorite_of"` } type WorkspaceAgent struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d19d91062efd0..7996e3ca22f15 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -75,7 +75,7 @@ type sqlcQuerier interface { DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) - FavoriteWorkspace(ctx context.Context, arg FavoriteWorkspaceParams) error + FavoriteWorkspace(ctx context.Context, id uuid.UUID) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) // there is no unique constraint on empty token names GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) @@ -322,7 +322,7 @@ type sqlcQuerier interface { TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) // This will always work regardless of the current state of the template version. UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error - UnfavoriteWorkspace(ctx context.Context, arg UnfavoriteWorkspaceParams) error + UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d51e524dce433..03b70654071cb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -10850,16 +10850,11 @@ func (q *sqlQuerier) BatchUpdateWorkspaceLastUsedAt(ctx context.Context, arg Bat } const favoriteWorkspace = `-- name: FavoriteWorkspace :exec -INSERT INTO favorite_workspaces (user_id, workspace_id) VALUES ($1, $2) +UPDATE workspaces SET favorite_of = workspaces.owner_id WHERE id = $1 ` -type FavoriteWorkspaceParams struct { - UserID uuid.UUID `db:"user_id" json:"user_id"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` -} - -func (q *sqlQuerier) FavoriteWorkspace(ctx context.Context, arg FavoriteWorkspaceParams) error { - _, err := q.db.ExecContext(ctx, favoriteWorkspace, arg.UserID, arg.WorkspaceID) +func (q *sqlQuerier) FavoriteWorkspace(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, favoriteWorkspace, id) return err } @@ -10949,7 +10944,7 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite_of, templates.name as template_name FROM workspaces @@ -11003,6 +10998,7 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI &i.Workspace.DormantAt, &i.Workspace.DeletingAt, &i.Workspace.AutomaticUpdates, + &i.Workspace.FavoriteOf, &i.TemplateName, ) return i, err @@ -11010,7 +11006,7 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI const getWorkspaceByID = `-- name: GetWorkspaceByID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite_of FROM workspaces WHERE @@ -11037,13 +11033,14 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.FavoriteOf, ) return i, err } const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite_of FROM workspaces WHERE @@ -11077,13 +11074,14 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.FavoriteOf, ) return i, err } const getWorkspaceByWorkspaceAppID = `-- name: GetWorkspaceByWorkspaceAppID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite_of FROM workspaces WHERE @@ -11136,6 +11134,7 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.FavoriteOf, ) return i, err } @@ -11180,11 +11179,10 @@ func (q *sqlQuerier) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Conte const getWorkspaces = `-- name: GetWorkspaces :many SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite_of, COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, - (fws.user_id IS NOT NULL)::boolean AS favorite, COUNT(*) OVER () as count FROM workspaces @@ -11229,47 +11227,34 @@ LEFT JOIN LATERAL ( WHERE templates.id = workspaces.template_id ) template_name ON true -LEFT JOIN LATERAL ( - SELECT - user_id - FROM - favorite_workspaces - WHERE - workspaces.id = favorite_workspaces.workspace_id - AND - -- Omitting the owner_id parameter will result in - -- 00000000-0000-0000-0000-000000000000 which will not match - -- any rows in favorite_workspaces. - favorite_workspaces.user_id = $1 -) fws ON TRUE WHERE -- Optionally include deleted workspaces - workspaces.deleted = $2 + workspaces.deleted = $1 AND CASE - WHEN $3 :: text != '' THEN + WHEN $2 :: text != '' THEN CASE -- Some workspace specific status refer to the transition -- type. By default, the standard provisioner job status -- search strings are supported. -- 'running' states - WHEN $3 = 'starting' THEN + WHEN $2 = 'starting' THEN latest_build.job_status = 'running'::provisioner_job_status AND latest_build.transition = 'start'::workspace_transition - WHEN $3 = 'stopping' THEN + WHEN $2 = 'stopping' THEN latest_build.job_status = 'running'::provisioner_job_status AND latest_build.transition = 'stop'::workspace_transition - WHEN $3 = 'deleting' THEN + WHEN $2 = 'deleting' THEN latest_build.job_status = 'running' AND latest_build.transition = 'delete'::workspace_transition -- 'succeeded' states - WHEN $3 = 'deleted' THEN + WHEN $2 = 'deleted' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'delete'::workspace_transition - WHEN $3 = 'stopped' THEN + WHEN $2 = 'stopped' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'stop'::workspace_transition - WHEN $3 = 'started' THEN + WHEN $2 = 'started' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'start'::workspace_transition @@ -11277,13 +11262,13 @@ WHERE -- differ. A workspace is "running" if the job is "succeeded" and -- the transition is "start". This is because a workspace starts -- running when a job is complete. - WHEN $3 = 'running' THEN + WHEN $2 = 'running' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'start'::workspace_transition - WHEN $3 != '' THEN + WHEN $2 != '' THEN -- By default just match the job status exactly - latest_build.job_status = $3::provisioner_job_status + latest_build.job_status = $2::provisioner_job_status ELSE true END @@ -11291,8 +11276,8 @@ WHERE END -- Filter by owner_id AND CASE - WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - workspaces.owner_id = $1 + WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspaces.owner_id = $3 ELSE true END -- Filter by owner_name @@ -11378,7 +11363,6 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY - favorite DESC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND @@ -11395,9 +11379,9 @@ OFFSET ` type GetWorkspacesParams struct { - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` Deleted bool `db:"deleted" json:"deleted"` Status string `db:"status" json:"status"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` OwnerUsername string `db:"owner_username" json:"owner_username"` TemplateName string `db:"template_name" json:"template_name"` TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` @@ -11426,18 +11410,18 @@ type GetWorkspacesRow struct { DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` + FavoriteOf uuid.NullUUID `db:"favorite_of" json:"favorite_of"` TemplateName string `db:"template_name" json:"template_name"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` - Favorite bool `db:"favorite" json:"favorite"` Count int64 `db:"count" json:"count"` } func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) { rows, err := q.db.QueryContext(ctx, getWorkspaces, - arg.OwnerID, arg.Deleted, arg.Status, + arg.OwnerID, arg.OwnerUsername, arg.TemplateName, pq.Array(arg.TemplateIDs), @@ -11472,10 +11456,10 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.FavoriteOf, &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, - &i.Favorite, &i.Count, ); err != nil { return nil, err @@ -11493,7 +11477,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite_of FROM workspaces LEFT JOIN @@ -11581,6 +11565,7 @@ func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.FavoriteOf, ); err != nil { return nil, err } @@ -11611,7 +11596,7 @@ INSERT INTO automatic_updates ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite_of ` type InsertWorkspaceParams struct { @@ -11658,21 +11643,17 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.FavoriteOf, ) return i, err } const unfavoriteWorkspace = `-- name: UnfavoriteWorkspace :exec -DELETE FROM favorite_workspaces WHERE user_id = $1 AND workspace_id = $2 +UPDATE workspaces SET favorite_of = NULL WHERE id = $1 ` -type UnfavoriteWorkspaceParams struct { - UserID uuid.UUID `db:"user_id" json:"user_id"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` -} - -func (q *sqlQuerier) UnfavoriteWorkspace(ctx context.Context, arg UnfavoriteWorkspaceParams) error { - _, err := q.db.ExecContext(ctx, unfavoriteWorkspace, arg.UserID, arg.WorkspaceID) +func (q *sqlQuerier) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, unfavoriteWorkspace, id) return err } @@ -11702,7 +11683,7 @@ SET WHERE id = $1 AND deleted = false -RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates +RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite_of ` type UpdateWorkspaceParams struct { @@ -11728,6 +11709,7 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.FavoriteOf, ) return i, err } @@ -11814,7 +11796,7 @@ WHERE workspaces.id = $1 AND templates.id = workspaces.template_id RETURNING - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite_of ` type UpdateWorkspaceDormantDeletingAtParams struct { @@ -11840,6 +11822,7 @@ func (q *sqlQuerier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg U &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.FavoriteOf, ) return i, err } diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index b97a1b3f0b872..ea8be85a5af3a 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -82,7 +82,6 @@ SELECT COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, - (fws.user_id IS NOT NULL)::boolean AS favorite, COUNT(*) OVER () as count FROM workspaces @@ -127,19 +126,6 @@ LEFT JOIN LATERAL ( WHERE templates.id = workspaces.template_id ) template_name ON true -LEFT JOIN LATERAL ( - SELECT - user_id - FROM - favorite_workspaces - WHERE - workspaces.id = favorite_workspaces.workspace_id - AND - -- Omitting the owner_id parameter will result in - -- 00000000-0000-0000-0000-000000000000 which will not match - -- any rows in favorite_workspaces. - favorite_workspaces.user_id = @owner_id -) fws ON TRUE WHERE -- Optionally include deleted workspaces workspaces.deleted = @deleted @@ -276,7 +262,6 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY - favorite DESC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND @@ -564,7 +549,7 @@ WHERE id = $1; -- name: FavoriteWorkspace :exec -INSERT INTO favorite_workspaces (user_id, workspace_id) VALUES (sqlc.arg(user_id), sqlc.arg(workspace_id)); +UPDATE workspaces SET favorite_of = workspaces.owner_id WHERE id = @id; -- name: UnfavoriteWorkspace :exec -DELETE FROM favorite_workspaces WHERE user_id = sqlc.arg(user_id) AND workspace_id = sqlc.arg(workspace_id); +UPDATE workspaces SET favorite_of = NULL WHERE id = @id; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index c9fb2e8be63b7..f397692f1d6d1 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -12,7 +12,6 @@ const ( UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); UniqueDbcryptKeysPkey UniqueConstraint = "dbcrypt_keys_pkey" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_pkey PRIMARY KEY (number); UniqueDbcryptKeysRevokedKeyDigestKey UniqueConstraint = "dbcrypt_keys_revoked_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_revoked_key_digest_key UNIQUE (revoked_key_digest); - UniqueFavoriteWorkspacesUserIDWorkspaceIDKey UniqueConstraint = "favorite_workspaces_user_id_workspace_id_key" // ALTER TABLE ONLY favorite_workspaces ADD CONSTRAINT favorite_workspaces_user_id_workspace_id_key UNIQUE (user_id, workspace_id); UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); UniqueFilesPkey UniqueConstraint = "files_pkey" // ALTER TABLE ONLY files ADD CONSTRAINT files_pkey PRIMARY KEY (id); UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); diff --git a/coderd/workspaces.go b/coderd/workspaces.go index de3b5d4a022fb..ad76533bfabbc 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1032,10 +1032,9 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { func (api *API) putFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() - apiKey = httpmw.APIKey(r) workspace = httpmw.WorkspaceParam(r) auditor = api.Auditor.Load() - aReq, commitAudit = audit.InitRequest[database.FavoriteWorkspace](rw, &audit.RequestParams{ + aReq, commitAudit = audit.InitRequest[database.Workspace](rw, &audit.RequestParams{ Audit: *auditor, Log: api.Logger, Request: r, @@ -1043,24 +1042,20 @@ func (api *API) putFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) { }) ) defer commitAudit() - aReq.Old = database.FavoriteWorkspace{} + aReq.Old = workspace - err := api.Database.FavoriteWorkspace(ctx, database.FavoriteWorkspaceParams{ - UserID: apiKey.UserID, - WorkspaceID: workspace.ID, - }) + err := api.Database.FavoriteWorkspace(ctx, workspace.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error pinning workspace", + Message: "Internal error setting workspace as favorite", Detail: err.Error(), }) return } - aReq.New = database.FavoriteWorkspace{ - UserID: apiKey.UserID, - WorkspaceID: workspace.ID, - } + aReq.New = workspace + aReq.New.FavoriteOf.Valid = true + aReq.New.FavoriteOf.UUID = workspace.OwnerID rw.WriteHeader(http.StatusNoContent) } @@ -1076,10 +1071,9 @@ func (api *API) putFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) { func (api *API) deleteFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() - apiKey = httpmw.APIKey(r) workspace = httpmw.WorkspaceParam(r) auditor = api.Auditor.Load() - aReq, commitAudit = audit.InitRequest[database.FavoriteWorkspace](rw, &audit.RequestParams{ + aReq, commitAudit = audit.InitRequest[database.Workspace](rw, &audit.RequestParams{ Audit: *auditor, Log: api.Logger, Request: r, @@ -1087,23 +1081,18 @@ func (api *API) deleteFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) }) ) defer commitAudit() - aReq.Old = database.FavoriteWorkspace{ - UserID: apiKey.UserID, - WorkspaceID: workspace.ID, - } + aReq.Old = workspace - err := api.Database.UnfavoriteWorkspace(ctx, database.UnfavoriteWorkspaceParams{ - UserID: apiKey.UserID, - WorkspaceID: workspace.ID, - }) + err := api.Database.UnfavoriteWorkspace(ctx, workspace.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error unpinning workspace", + Message: "Internal error unsetting workspace as favorite", Detail: err.Error(), }) return } - aReq.New = database.FavoriteWorkspace{} + aReq.New = workspace + aReq.New.FavoriteOf = uuid.NullUUID{} rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 0950443c14e99..cac319a8dd28c 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2962,7 +2962,7 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { require.False(t, workspaces.Workspaces[0].Favorite) require.False(t, workspaces.Workspaces[1].Favorite) - // When member pins workspace + // When member favorites workspace err = memberClient.FavoriteWorkspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) @@ -2985,7 +2985,7 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { require.NoError(t, err) require.False(t, ws.Favorite) - // When member unpins workspace + // When member unfavorites workspace err = memberClient.UnfavoriteWorkspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) @@ -2998,7 +2998,7 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { require.NoError(t, err) require.False(t, ws.Favorite) - // Assert invariant: workspace should remain unpinned for a different user + // Assert invariant: workspace should remain unfavorited for a different user workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) require.Len(t, workspaces.Workspaces, 2) diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index c09c829f3b765..2dada4f22bb5c 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -19,7 +19,7 @@ We track the following resources: | Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_max_ttltrue
user_acltrue
| | TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| | User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
nametrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
theme_preferencefalse
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favorite_oftrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| | WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index c7e9272adfe40..94421649c700d 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -137,6 +137,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "dormant_at": ActionTrack, "deleting_at": ActionTrack, "automatic_updates": ActionTrack, + "favorite_of": ActionTrack, }, &database.WorkspaceBuild{}: { "id": ActionIgnore, From 4eef59aed5cd395e0861ae1734e87283895f1268 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 15:19:30 +0000 Subject: [PATCH 11/27] more wiring --- coderd/coderd.go | 4 ++-- coderd/database/dbmem/dbmem.go | 1 + coderd/database/modelmethods.go | 1 + coderd/workspaces.go | 18 +++++++++++++++--- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index e43cec63e8303..7afbbc1f44418 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -950,8 +950,8 @@ func New(options *Options) *API { r.Get("/watch", api.watchWorkspace) r.Put("/extend", api.putExtendWorkspace) r.Put("/dormant", api.putWorkspaceDormant) - r.Put("/pin", api.putFavoriteWorkspace) - r.Delete("/pin", api.deleteFavoriteWorkspace) + r.Put("/favorite", api.putFavoriteWorkspace) + r.Delete("/favorite", api.deleteFavoriteWorkspace) r.Put("/autoupdates", api.putWorkspaceAutoupdates) r.Get("/resolve-autostart", api.resolveAutostart) }) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index d3e0d07233efa..dcaf82bfd79fe 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -359,6 +359,7 @@ func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspac DeletingAt: w.DeletingAt, Count: count, AutomaticUpdates: w.AutomaticUpdates, + FavoriteOf: w.FavoriteOf, } for _, t := range q.templates { diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 685c138c95288..81d244994601e 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -373,6 +373,7 @@ func ConvertWorkspaceRows(rows []GetWorkspacesRow) []Workspace { DormantAt: r.DormantAt, DeletingAt: r.DeletingAt, AutomaticUpdates: r.AutomaticUpdates, + FavoriteOf: r.FavoriteOf, } } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index ad76533bfabbc..6442c568b2426 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -55,6 +55,7 @@ var ( func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspace := httpmw.WorkspaceParam(r) + apiKey := httpmw.APIKey(r) var ( deletedStr = r.URL.Query().Get("include_deleted") @@ -102,6 +103,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { return } httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace( + apiKey.UserID, workspace, data.builds[0], data.templates[0], @@ -184,7 +186,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { return } - wss, err := convertWorkspaces(workspaces, data) + wss, err := convertWorkspaces(apiKey.UserID, workspaces, data) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error converting workspaces.", @@ -213,6 +215,7 @@ func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) ctx := r.Context() owner := httpmw.UserParam(r) workspaceName := chi.URLParam(r, "workspacename") + apiKey := httpmw.APIKey(r) includeDeleted := false if s := r.URL.Query().Get("include_deleted"); s != "" { @@ -274,6 +277,7 @@ func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) return } httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace( + apiKey.UserID, workspace, data.builds[0], data.templates[0], @@ -583,6 +587,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req } httpapi.Write(ctx, rw, http.StatusCreated, convertWorkspace( + apiKey.UserID, workspace, apiBuild, template, @@ -854,6 +859,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() workspace = httpmw.WorkspaceParam(r) + apiKey = httpmw.APIKey(r) oldWorkspace = workspace auditor = api.Auditor.Load() aReq, commitAudit = audit.InitRequest[database.Workspace](rw, &audit.RequestParams{ @@ -922,6 +928,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { aReq.New = workspace httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace( + apiKey.UserID, workspace, data.builds[0], data.templates[0], @@ -1262,6 +1269,7 @@ func (api *API) resolveAutostart(rw http.ResponseWriter, r *http.Request) { func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspace := httpmw.WorkspaceParam(r) + apiKey := httpmw.APIKey(r) sendEvent, senderClosed, err := httpapi.ServerSentEventSender(rw, r) if err != nil { @@ -1324,6 +1332,7 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) { _ = sendEvent(ctx, codersdk.ServerSentEvent{ Type: codersdk.ServerSentEventTypeData, Data: convertWorkspace( + apiKey.UserID, workspace, data.builds[0], data.templates[0], @@ -1442,7 +1451,7 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa }, nil } -func convertWorkspaces(workspaces []database.Workspace, data workspaceData) ([]codersdk.Workspace, error) { +func convertWorkspaces(requestorID uuid.UUID, workspaces []database.Workspace, data workspaceData) ([]codersdk.Workspace, error) { buildByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceBuild{} for _, workspaceBuild := range data.builds { buildByWorkspaceID[workspaceBuild.WorkspaceID] = workspaceBuild @@ -1477,6 +1486,7 @@ func convertWorkspaces(workspaces []database.Workspace, data workspaceData) ([]c } apiWorkspaces = append(apiWorkspaces, convertWorkspace( + requestorID, workspace, build, template, @@ -1488,6 +1498,7 @@ func convertWorkspaces(workspaces []database.Workspace, data workspaceData) ([]c } func convertWorkspace( + requestorID uuid.UUID, workspace database.Workspace, workspaceBuild codersdk.WorkspaceBuild, template database.Template, @@ -1519,6 +1530,7 @@ func convertWorkspace( } ttlMillis := convertWorkspaceTTLMillis(workspace.Ttl) + requestorFavorite := workspace.FavoriteOf.UUID == requestorID return codersdk.Workspace{ ID: workspace.ID, @@ -1548,7 +1560,7 @@ func convertWorkspace( }, AutomaticUpdates: codersdk.AutomaticUpdates(workspace.AutomaticUpdates), AllowRenames: allowRenames, - // Pinned: pinned, // TODO + Favorite: requestorFavorite, } } From 0f8904d8859776c3ab8cea6e7a4fd09b446f3c58 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 15:42:15 +0000 Subject: [PATCH 12/27] improve test to include sort order of favorites --- coderd/database/dbmem/dbmem.go | 10 +++++- coderd/database/queries.sql.go | 1 + coderd/database/queries/workspaces.sql | 1 + coderd/workspaces_test.go | 46 ++++++++++++++++---------- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index dcaf82bfd79fe..505c638096d16 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -7737,7 +7737,15 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. w1 := workspaces[i] w2 := workspaces[j] - // Order by: running first + // Order by: favorite first + if w1.FavoriteOf.Valid && !w2.FavoriteOf.Valid { + return true + } + if !w1.FavoriteOf.Valid && w2.FavoriteOf.Valid { + return false + } + + // Order by: running w1IsRunning := isRunning(preloadedWorkspaceBuilds[w1.ID], preloadedProvisionerJobs[w1.ID]) w2IsRunning := isRunning(preloadedWorkspaceBuilds[w2.ID], preloadedProvisionerJobs[w2.ID]) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 03b70654071cb..07b750c55d88a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11363,6 +11363,7 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY + favorite_of IS NOT NULL AND (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index ea8be85a5af3a..94829a448f3b2 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -262,6 +262,7 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY + favorite_of IS NOT NULL AND (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index cac319a8dd28c..e0f92e07edab7 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2939,8 +2939,12 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { }) owner = coderdtest.CreateFirstUser(t, client) memberClient, member = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - wsb1 = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do() - _ = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: owner.UserID, OrganizationID: owner.OrganizationID}).Do() + // This will be our 'favorite' workspace + wsb1 = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do() + // Another workspace for member, but not their favorite. + _ = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do() + // A workspace for another user. + _ = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: owner.UserID, OrganizationID: owner.OrganizationID}).Do() ) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -2949,8 +2953,9 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { // Initially, workspace should not be favored for member. workspaces, err := memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Favorite) + require.Len(t, workspaces.Workspaces, 2) + require.False(t, workspaces.Workspaces[0].Favorite, "no favorites yet") + require.False(t, workspaces.Workspaces[1].Favorite, "no favorites yet") ws, err := memberClient.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) require.False(t, ws.Favorite) @@ -2958,19 +2963,21 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { // Also not for owner. workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 2) - require.False(t, workspaces.Workspaces[0].Favorite) - require.False(t, workspaces.Workspaces[1].Favorite) + require.Len(t, workspaces.Workspaces, 3) + require.False(t, workspaces.Workspaces[0].Favorite, "this user is impartial and has no favorites") + require.False(t, workspaces.Workspaces[1].Favorite, "this user is impartial and has no favorites") + require.False(t, workspaces.Workspaces[2].Favorite, "this user is impartial and has no favorites") // When member favorites workspace err = memberClient.FavoriteWorkspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - // Then it should be favored for them + // Then it should be favored for them and show up first. workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 1) - require.True(t, workspaces.Workspaces[0].Favorite) + require.Len(t, workspaces.Workspaces, 2) + require.True(t, workspaces.Workspaces[0].Favorite, "favorites should come first") + require.False(t, workspaces.Workspaces[1].Favorite, "favorites should come first") ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) require.True(t, ws.Favorite) @@ -2978,9 +2985,10 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { // But not for someone else workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 2) - require.False(t, workspaces.Workspaces[0].Favorite) - require.False(t, workspaces.Workspaces[1].Favorite) + require.Len(t, workspaces.Workspaces, 3) + require.False(t, workspaces.Workspaces[0].Favorite, "this user is impartial and has no favorites") + require.False(t, workspaces.Workspaces[1].Favorite, "this user is impartial and has no favorites") + require.False(t, workspaces.Workspaces[2].Favorite, "this user is impartial and has no favorites") ws, err = client.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) require.False(t, ws.Favorite) @@ -2992,8 +3000,9 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { // Then it should no longer be favored workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Favorite) + require.Len(t, workspaces.Workspaces, 2) + require.False(t, workspaces.Workspaces[0].Favorite, "no longer favorite") + require.False(t, workspaces.Workspaces[1].Favorite, "no longer favorite") ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) require.False(t, ws.Favorite) @@ -3001,9 +3010,10 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { // Assert invariant: workspace should remain unfavorited for a different user workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 2) - require.False(t, workspaces.Workspaces[0].Favorite) - require.False(t, workspaces.Workspaces[1].Favorite) + require.Len(t, workspaces.Workspaces, 3) + require.False(t, workspaces.Workspaces[0].Favorite, "this user is impartial and has no favorites") + require.False(t, workspaces.Workspaces[1].Favorite, "this user is impartial and has no favorites") + require.False(t, workspaces.Workspaces[2].Favorite, "this user is impartial and has no favorites") ws, err = client.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) require.False(t, ws.Favorite) From d921aa0ef86b01924fcd3e7fa3692cec526b1903 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 17:32:07 +0000 Subject: [PATCH 13/27] fix swagger summary --- coderd/workspaces.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 6442c568b2426..4ce6c3ecd0dee 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1028,10 +1028,9 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, code, resp) } -// @Summary Favor workspace by ID. +// @Summary Favorite workspace by ID. // @ID favorite-workspace-by-id // @Security CoderSessionToken -// @Accept json // @Tags Workspaces // @Param workspace path string true "Workspace ID" format(uuid) // @Success 204 @@ -1067,10 +1066,9 @@ func (api *API) putFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } -// @Summary Unfavor workspace by ID. +// @Summary Unfavorite workspace by ID. // @ID unfavorite-workspace-by-id // @Security CoderSessionToken -// @Accept json // @Tags Workspaces // @Param workspace path string true "Workspace ID" format(uuid) // @Success 204 From b68c18ee11ff6a2272fb9a37e21fe7b9b37b3799 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 19:32:47 +0000 Subject: [PATCH 14/27] make gen --- cli/testdata/coder_list_--output_json.golden | 3 ++- coderd/apidoc/docs.go | 10 ++-------- coderd/apidoc/swagger.json | 6 ++---- docs/api/workspaces.md | 4 ++-- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index d1874e6f7ca14..319ac80c1554c 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -61,6 +61,7 @@ "failing_agents": [] }, "automatic_updates": "never", - "allow_renames": false + "allow_renames": false, + "favorite": false } ] diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7e95d2ad04ee2..d7d7d4abd033d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7006,13 +7006,10 @@ const docTemplate = `{ "CoderSessionToken": [] } ], - "consumes": [ - "application/json" - ], "tags": [ "Workspaces" ], - "summary": "Favor workspace by ID.", + "summary": "Favorite workspace by ID.", "operationId": "favorite-workspace-by-id", "parameters": [ { @@ -7036,13 +7033,10 @@ const docTemplate = `{ "CoderSessionToken": [] } ], - "consumes": [ - "application/json" - ], "tags": [ "Workspaces" ], - "summary": "Unfavor workspace by ID.", + "summary": "Unfavorite workspace by ID.", "operationId": "unfavorite-workspace-by-id", "parameters": [ { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 741f471a4a07f..875d8010ec113 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6182,9 +6182,8 @@ "CoderSessionToken": [] } ], - "consumes": ["application/json"], "tags": ["Workspaces"], - "summary": "Favor workspace by ID.", + "summary": "Favorite workspace by ID.", "operationId": "favorite-workspace-by-id", "parameters": [ { @@ -6208,9 +6207,8 @@ "CoderSessionToken": [] } ], - "consumes": ["application/json"], "tags": ["Workspaces"], - "summary": "Unfavor workspace by ID.", + "summary": "Unfavorite workspace by ID.", "operationId": "unfavorite-workspace-by-id", "parameters": [ { diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index c3304ac5172e1..f4c1b6957f527 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -1250,7 +1250,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/extend \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Favor workspace by ID. +## Favorite workspace by ID. ### Code samples @@ -1276,7 +1276,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/favorite \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Unfavor workspace by ID. +## Unfavorite workspace by ID. ### Code samples From 3d0545a62c9121ea0cdf47b2e903130058eed96d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 19:46:11 +0000 Subject: [PATCH 15/27] use dbfake for testing sort order --- coderd/workspaces_test.go | 46 +++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index e0f92e07edab7..3501c3d1c280a 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -479,32 +479,17 @@ func TestAdminViewAllWorkspaces(t *testing.T) { func TestWorkspacesSortOrder(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + client, db := coderdtest.NewWithDatabase(t, nil) firstUser := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID) // c-workspace should be running - workspace1 := coderdtest.CreateWorkspace(t, client, firstUser.OrganizationID, template.ID, func(ctr *codersdk.CreateWorkspaceRequest) { - ctr.Name = "c-workspace" - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace1.LatestBuild.ID) + wsb1 := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "c-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Do() // b-workspace should be stopped - workspace2 := coderdtest.CreateWorkspace(t, client, firstUser.OrganizationID, template.ID, func(ctr *codersdk.CreateWorkspaceRequest) { - ctr.Name = "b-workspace" - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace2.LatestBuild.ID) - - build2 := coderdtest.CreateWorkspaceBuild(t, client, workspace2, database.WorkspaceTransitionStop) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build2.ID) + wsb2 := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "b-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do() // a-workspace should be running - workspace3 := coderdtest.CreateWorkspace(t, client, firstUser.OrganizationID, template.ID, func(ctr *codersdk.CreateWorkspaceRequest) { - ctr.Name = "a-workspace" - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace3.LatestBuild.ID) + wsb3 := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "a-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Do() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -512,22 +497,31 @@ func TestWorkspacesSortOrder(t *testing.T) { require.NoError(t, err, "(first) fetch workspaces") workspaces := workspacesResponse.Workspaces - expected := []string{ - workspace3.Name, - workspace1.Name, - workspace2.Name, + expectedNames := []string{ + wsb3.Workspace.Name, + wsb1.Workspace.Name, + wsb2.Workspace.Name, + } + + expectedStatus := []codersdk.WorkspaceStatus{ + codersdk.WorkspaceStatusRunning, + codersdk.WorkspaceStatusRunning, + codersdk.WorkspaceStatusStopped, } - var actual []string + var actualNames []string + var actualStatus []codersdk.WorkspaceStatus for _, w := range workspaces { - actual = append(actual, w.Name) + actualNames = append(actualNames, w.Name) + actualStatus = append(actualStatus, w.LatestBuild.Status) } // the correct sorting order is: // 1. Running workspaces // 2. Sort by usernames // 3. Sort by workspace names - require.Equal(t, expected, actual) + assert.Equal(t, expectedNames, actualNames) + assert.Equal(t, expectedStatus, actualStatus) } func TestPostWorkspacesByOrganization(t *testing.T) { From 46103a481b2a0fc48d87625772bf09807cf26d07 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 20:11:45 +0000 Subject: [PATCH 16/27] reduce scope of TestWorkspaceFavoriteUnfavorite to not include sorting order --- coderd/workspaces_test.go | 76 +++++++++++++-------------------------- codersdk/workspaces.go | 4 +-- 2 files changed, 26 insertions(+), 54 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 3501c3d1c280a..d3648d9b3c88d 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2931,84 +2931,56 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ Auditor: auditRecorder, }) - owner = coderdtest.CreateFirstUser(t, client) - memberClient, member = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + owner = coderdtest.CreateFirstUser(t, client) + memberClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) // This will be our 'favorite' workspace - wsb1 = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do() - // Another workspace for member, but not their favorite. - _ = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do() - // A workspace for another user. - _ = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: owner.UserID, OrganizationID: owner.OrganizationID}).Do() + wsb = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: owner.UserID, OrganizationID: owner.OrganizationID}).Do() ) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() // Initially, workspace should not be favored for member. - workspaces, err := memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) + workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 2) + require.Len(t, workspaces.Workspaces, 1) require.False(t, workspaces.Workspaces[0].Favorite, "no favorites yet") - require.False(t, workspaces.Workspaces[1].Favorite, "no favorites yet") - ws, err := memberClient.Workspace(ctx, wsb1.Workspace.ID) + ws, err := client.Workspace(ctx, wsb.Workspace.ID) require.NoError(t, err) require.False(t, ws.Favorite) - // Also not for owner. - workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 3) - require.False(t, workspaces.Workspaces[0].Favorite, "this user is impartial and has no favorites") - require.False(t, workspaces.Workspaces[1].Favorite, "this user is impartial and has no favorites") - require.False(t, workspaces.Workspaces[2].Favorite, "this user is impartial and has no favorites") - - // When member favorites workspace - err = memberClient.FavoriteWorkspace(ctx, wsb1.Workspace.ID) + // When user favorites workspace + err = client.FavoriteWorkspace(ctx, wsb.Workspace.ID) require.NoError(t, err) - // Then it should be favored for them and show up first. - workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) + // Then it should be favored for them. + workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 2) + require.Len(t, workspaces.Workspaces, 1) require.True(t, workspaces.Workspaces[0].Favorite, "favorites should come first") - require.False(t, workspaces.Workspaces[1].Favorite, "favorites should come first") - ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) + ws, err = client.Workspace(ctx, wsb.Workspace.ID) require.NoError(t, err) require.True(t, ws.Favorite) - // But not for someone else - workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 3) - require.False(t, workspaces.Workspaces[0].Favorite, "this user is impartial and has no favorites") - require.False(t, workspaces.Workspaces[1].Favorite, "this user is impartial and has no favorites") - require.False(t, workspaces.Workspaces[2].Favorite, "this user is impartial and has no favorites") - ws, err = client.Workspace(ctx, wsb1.Workspace.ID) - require.NoError(t, err) - require.False(t, ws.Favorite) - // When member unfavorites workspace - err = memberClient.UnfavoriteWorkspace(ctx, wsb1.Workspace.ID) + err = client.UnfavoriteWorkspace(ctx, wsb.Workspace.ID) require.NoError(t, err) // Then it should no longer be favored - workspaces, err = memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) + workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 2) + require.Len(t, workspaces.Workspaces, 1) require.False(t, workspaces.Workspaces[0].Favorite, "no longer favorite") - require.False(t, workspaces.Workspaces[1].Favorite, "no longer favorite") - ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) + ws, err = client.Workspace(ctx, wsb.Workspace.ID) require.NoError(t, err) require.False(t, ws.Favorite) - // Assert invariant: workspace should remain unfavorited for a different user - workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 3) - require.False(t, workspaces.Workspaces[0].Favorite, "this user is impartial and has no favorites") - require.False(t, workspaces.Workspaces[1].Favorite, "this user is impartial and has no favorites") - require.False(t, workspaces.Workspaces[2].Favorite, "this user is impartial and has no favorites") - ws, err = client.Workspace(ctx, wsb1.Workspace.ID) - require.NoError(t, err) - require.False(t, ws.Favorite) + // Users without write access to the workspace should not be able to perform the above. + err = memberClient.FavoriteWorkspace(ctx, wsb.Workspace.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + err = memberClient.UnfavoriteWorkspace(ctx, wsb.Workspace.ID) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index cef8a8bae2a91..5cdd8cd1c6885 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -479,7 +479,7 @@ func (c *Client) FavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID) e } defer res.Body.Close() if res.StatusCode != http.StatusNoContent { - return err + return ReadBodyAsError(res) } return nil } @@ -491,7 +491,7 @@ func (c *Client) UnfavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID) } defer res.Body.Close() if res.StatusCode != http.StatusNoContent { - return err + return ReadBodyAsError(res) } return nil } From f6c036193ae2c07211b2901ad3bcd00bbbc3557e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 21:02:08 +0000 Subject: [PATCH 17/27] beef up sort order test --- coderd/coderdtest/coderdtest.go | 15 ++++++++++----- coderd/workspaces_test.go | 29 ++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 91ff7e17538d9..f578ad92ad0e7 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -600,14 +600,19 @@ var FirstUserParams = codersdk.CreateFirstUserRequest{ } // CreateFirstUser creates a user with preset credentials and authenticates -// with the passed in codersdk client. -func CreateFirstUser(t testing.TB, client *codersdk.Client) codersdk.CreateFirstUserResponse { - resp, err := client.CreateFirstUser(context.Background(), FirstUserParams) +// with the passed in codersdk client. Optionally, pass a function to mutate +// the first user create request as required. +func CreateFirstUser(t testing.TB, client *codersdk.Client, mutators ...func(*codersdk.CreateFirstUserRequest)) codersdk.CreateFirstUserResponse { + params := FirstUserParams + for _, mut := range mutators { + mut(¶ms) + } + resp, err := client.CreateFirstUser(context.Background(), params) require.NoError(t, err) login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ - Email: FirstUserParams.Email, - Password: FirstUserParams.Password, + Email: params.Email, + Password: params.Password, }) require.NoError(t, err) client.SetSessionToken(login.SessionToken) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index d3648d9b3c88d..140f50ee260cc 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -480,16 +480,27 @@ func TestWorkspacesSortOrder(t *testing.T) { t.Parallel() client, db := coderdtest.NewWithDatabase(t, nil) - firstUser := coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client, func(r *codersdk.CreateFirstUserRequest) { + r.Username = "aaa" + }) + _, secondUser := coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, []string{"owner"}, func(r *codersdk.CreateUserRequest) { + r.Username = "zzz" + }) // c-workspace should be running - wsb1 := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "c-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Do() + wsbC := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "c-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Do() // b-workspace should be stopped - wsb2 := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "b-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do() + wsbB := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "b-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do() // a-workspace should be running - wsb3 := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "a-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Do() + wsbA := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "a-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Do() + + // d-workspace should be stopped + wsbD := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "d-workspace", OwnerID: secondUser.ID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do() + + // e-workspace should also be stopped + wsbE := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "e-workspace", OwnerID: secondUser.ID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -498,15 +509,19 @@ func TestWorkspacesSortOrder(t *testing.T) { workspaces := workspacesResponse.Workspaces expectedNames := []string{ - wsb3.Workspace.Name, - wsb1.Workspace.Name, - wsb2.Workspace.Name, + wsbA.Workspace.Name, // running + wsbC.Workspace.Name, // running + wsbB.Workspace.Name, // stopped, aaa < zzz + wsbD.Workspace.Name, // stopped, zzz > aaa + wsbE.Workspace.Name, // stopped, zzz > aaa } expectedStatus := []codersdk.WorkspaceStatus{ codersdk.WorkspaceStatusRunning, codersdk.WorkspaceStatusRunning, codersdk.WorkspaceStatusStopped, + codersdk.WorkspaceStatusStopped, + codersdk.WorkspaceStatusStopped, } var actualNames []string From 89f0f5091acd80d7fc180c541be8b3ffb20c88fb Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 21:16:44 +0000 Subject: [PATCH 18/27] rm unnecessary fixture --- .../fixtures/000186_user_favorite_workspaces.up.sql | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 coderd/database/migrations/testdata/fixtures/000186_user_favorite_workspaces.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000186_user_favorite_workspaces.up.sql b/coderd/database/migrations/testdata/fixtures/000186_user_favorite_workspaces.up.sql deleted file mode 100644 index 039456ce1f456..0000000000000 --- a/coderd/database/migrations/testdata/fixtures/000186_user_favorite_workspaces.up.sql +++ /dev/null @@ -1,13 +0,0 @@ -INSERT INTO favorite_workspaces - (user_id, workspace_id) -VALUES ( - '30095c71-380b-457a-8995-97b8ee6e5307', - '3a9a1feb-e89d-457c-9d53-ac751b198ebe' -); - -INSERT INTO favorite_workspaces - (user_id, workspace_id) -VALUES ( - '0ed9befc-4911-4ccf-a8e2-559bf72daa94', - 'b90547be-8870-4d68-8184-e8b2242b7c01' -); From c9ed43c97b7fb7daa3be1eee2762f058878963c7 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 22 Jan 2024 22:31:04 +0000 Subject: [PATCH 19/27] fix sort ordering, dbfake still TODO --- coderd/database/modelqueries.go | 1 + coderd/database/queries.sql.go | 40 ++++++++++-------- coderd/database/queries/workspaces.sql | 4 +- coderd/workspaces.go | 4 ++ coderd/workspaces_test.go | 57 ++++++++++++++++++-------- 5 files changed, 70 insertions(+), 36 deletions(-) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index cd9572857a07b..2c4964d858032 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -225,6 +225,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.Dormant, arg.LastUsedBefore, arg.LastUsedAfter, + arg.OrderByFavorite, arg.Offset, arg.Limit, ) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 07b750c55d88a..27c15df6ba8e8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11363,7 +11363,9 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY - favorite_of IS NOT NULL AND + CASE WHEN workspaces.favorite_of = $13 THEN + workspaces.favorite_of = $13 + END ASC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND @@ -11372,28 +11374,29 @@ ORDER BY LOWER(workspaces.name) ASC LIMIT CASE - WHEN $14 :: integer > 0 THEN - $14 + WHEN $15 :: integer > 0 THEN + $15 END OFFSET - $13 + $14 ` type GetWorkspacesParams struct { - Deleted bool `db:"deleted" json:"deleted"` - Status string `db:"status" json:"status"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - OwnerUsername string `db:"owner_username" json:"owner_username"` - TemplateName string `db:"template_name" json:"template_name"` - TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` - Name string `db:"name" json:"name"` - HasAgent string `db:"has_agent" json:"has_agent"` - AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` - Dormant bool `db:"dormant" json:"dormant"` - LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"` - LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` - Offset int32 `db:"offset_" json:"offset_"` - Limit int32 `db:"limit_" json:"limit_"` + Deleted bool `db:"deleted" json:"deleted"` + Status string `db:"status" json:"status"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OwnerUsername string `db:"owner_username" json:"owner_username"` + TemplateName string `db:"template_name" json:"template_name"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` + Name string `db:"name" json:"name"` + HasAgent string `db:"has_agent" json:"has_agent"` + AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` + Dormant bool `db:"dormant" json:"dormant"` + LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"` + LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` + OrderByFavorite uuid.NullUUID `db:"order_by_favorite" json:"order_by_favorite"` + Offset int32 `db:"offset_" json:"offset_"` + Limit int32 `db:"limit_" json:"limit_"` } type GetWorkspacesRow struct { @@ -11432,6 +11435,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.Dormant, arg.LastUsedBefore, arg.LastUsedAfter, + arg.OrderByFavorite, arg.Offset, arg.Limit, ) diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 94829a448f3b2..3697f0c8fc3cd 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -262,7 +262,9 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY - favorite_of IS NOT NULL AND + CASE WHEN workspaces.favorite_of = @order_by_favorite THEN + workspaces.favorite_of = @order_by_favorite + END ASC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 4ce6c3ecd0dee..1097fe1ac12a7 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -159,6 +159,10 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { return } + // To show the user's favorite workspaces first, we pass their userID and compare it to + // column favorite_of when ordering the rows. + filter.OrderByFavorite = uuid.NullUUID{Valid: true, UUID: apiKey.UserID} + workspaceRows, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, prepared) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 140f50ee260cc..775aa59f40e60 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -479,11 +479,14 @@ func TestAdminViewAllWorkspaces(t *testing.T) { func TestWorkspacesSortOrder(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client, db := coderdtest.NewWithDatabase(t, nil) firstUser := coderdtest.CreateFirstUser(t, client, func(r *codersdk.CreateFirstUserRequest) { r.Username = "aaa" }) - _, secondUser := coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, []string{"owner"}, func(r *codersdk.CreateUserRequest) { + secondUserClient, secondUser := coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, []string{"owner"}, func(r *codersdk.CreateUserRequest) { r.Username = "zzz" }) @@ -502,13 +505,16 @@ func TestWorkspacesSortOrder(t *testing.T) { // e-workspace should also be stopped wsbE := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "e-workspace", OwnerID: secondUser.ID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + // f-workspace is also stopped, but is marked as favorite + wsbF := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "f-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do() + require.NoError(t, client.FavoriteWorkspace(ctx, wsbF.Workspace.ID)) // need to do this via API call for now + workspacesResponse, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err, "(first) fetch workspaces") workspaces := workspacesResponse.Workspaces expectedNames := []string{ + wsbF.Workspace.Name, // favorite wsbA.Workspace.Name, // running wsbC.Workspace.Name, // running wsbB.Workspace.Name, // stopped, aaa < zzz @@ -516,27 +522,44 @@ func TestWorkspacesSortOrder(t *testing.T) { wsbE.Workspace.Name, // stopped, zzz > aaa } - expectedStatus := []codersdk.WorkspaceStatus{ - codersdk.WorkspaceStatusRunning, - codersdk.WorkspaceStatusRunning, - codersdk.WorkspaceStatusStopped, - codersdk.WorkspaceStatusStopped, - codersdk.WorkspaceStatusStopped, + actualNames := make([]string, 0, len(expectedNames)) + for _, w := range workspaces { + actualNames = append(actualNames, w.Name) + } + + // the correct sorting order is: + // 1. Favorite workspaces (we have one, workspace-f) + // 2. Running workspaces + // 3. Sort by usernames + // 4. Sort by workspace names + require.Equal(t, expectedNames, actualNames) + + // Once again but this time as a different user. This time we do not expect to see another + // user's favorites first. + workspacesResponse, err = secondUserClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err, "(second) fetch workspaces") + workspaces = workspacesResponse.Workspaces + + expectedNames = []string{ + wsbA.Workspace.Name, // running + wsbC.Workspace.Name, // running + wsbB.Workspace.Name, // stopped, aaa < zzz + wsbF.Workspace.Name, // stopped, aaa < zzz + wsbD.Workspace.Name, // stopped, zzz > aaa + wsbE.Workspace.Name, // stopped, zzz > aaa } - var actualNames []string - var actualStatus []codersdk.WorkspaceStatus + actualNames = make([]string, 0, len(expectedNames)) for _, w := range workspaces { actualNames = append(actualNames, w.Name) - actualStatus = append(actualStatus, w.LatestBuild.Status) } // the correct sorting order is: - // 1. Running workspaces - // 2. Sort by usernames - // 3. Sort by workspace names - assert.Equal(t, expectedNames, actualNames) - assert.Equal(t, expectedStatus, actualStatus) + // 1. Favorite workspaces (we have none this time) + // 2. Running workspaces + // 3. Sort by usernames + // 4. Sort by workspace names + require.Equal(t, expectedNames, actualNames) } func TestPostWorkspacesByOrganization(t *testing.T) { From ff3cde296c2df33f2ee26c070b6a122a3c4f0508 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 23 Jan 2024 09:45:11 +0000 Subject: [PATCH 20/27] remove unnecessary change to CreateFirstUser --- coderd/coderdtest/coderdtest.go | 12 ++++-------- coderd/workspaces_test.go | 24 +++++++++++------------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index f578ad92ad0e7..7ce1cce2c6022 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -602,17 +602,13 @@ var FirstUserParams = codersdk.CreateFirstUserRequest{ // CreateFirstUser creates a user with preset credentials and authenticates // with the passed in codersdk client. Optionally, pass a function to mutate // the first user create request as required. -func CreateFirstUser(t testing.TB, client *codersdk.Client, mutators ...func(*codersdk.CreateFirstUserRequest)) codersdk.CreateFirstUserResponse { - params := FirstUserParams - for _, mut := range mutators { - mut(¶ms) - } - resp, err := client.CreateFirstUser(context.Background(), params) +func CreateFirstUser(t testing.TB, client *codersdk.Client) codersdk.CreateFirstUserResponse { + resp, err := client.CreateFirstUser(context.Background(), FirstUserParams) require.NoError(t, err) login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ - Email: params.Email, - Password: params.Password, + Email: FirstUserParams.Email, + Password: FirstUserParams.Password, }) require.NoError(t, err) client.SetSessionToken(login.SessionToken) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 775aa59f40e60..3abb873eeccb0 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -479,13 +479,8 @@ func TestAdminViewAllWorkspaces(t *testing.T) { func TestWorkspacesSortOrder(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - client, db := coderdtest.NewWithDatabase(t, nil) - firstUser := coderdtest.CreateFirstUser(t, client, func(r *codersdk.CreateFirstUserRequest) { - r.Username = "aaa" - }) + firstUser := coderdtest.CreateFirstUser(t, client) secondUserClient, secondUser := coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, []string{"owner"}, func(r *codersdk.CreateUserRequest) { r.Username = "zzz" }) @@ -507,6 +502,9 @@ func TestWorkspacesSortOrder(t *testing.T) { // f-workspace is also stopped, but is marked as favorite wsbF := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "f-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() require.NoError(t, client.FavoriteWorkspace(ctx, wsbF.Workspace.ID)) // need to do this via API call for now workspacesResponse, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) @@ -517,9 +515,9 @@ func TestWorkspacesSortOrder(t *testing.T) { wsbF.Workspace.Name, // favorite wsbA.Workspace.Name, // running wsbC.Workspace.Name, // running - wsbB.Workspace.Name, // stopped, aaa < zzz - wsbD.Workspace.Name, // stopped, zzz > aaa - wsbE.Workspace.Name, // stopped, zzz > aaa + wsbB.Workspace.Name, // stopped, testuser < zzz + wsbD.Workspace.Name, // stopped, zzz > testuser + wsbE.Workspace.Name, // stopped, zzz > testuser } actualNames := make([]string, 0, len(expectedNames)) @@ -543,10 +541,10 @@ func TestWorkspacesSortOrder(t *testing.T) { expectedNames = []string{ wsbA.Workspace.Name, // running wsbC.Workspace.Name, // running - wsbB.Workspace.Name, // stopped, aaa < zzz - wsbF.Workspace.Name, // stopped, aaa < zzz - wsbD.Workspace.Name, // stopped, zzz > aaa - wsbE.Workspace.Name, // stopped, zzz > aaa + wsbB.Workspace.Name, // stopped, testuser < zzz + wsbF.Workspace.Name, // stopped, testuser < zzz + wsbD.Workspace.Name, // stopped, zzz > testuser + wsbE.Workspace.Name, // stopped, zzz > testuser } actualNames = make([]string, 0, len(expectedNames)) From f735b691216706889947458de418d94bca159822 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 23 Jan 2024 10:42:49 +0000 Subject: [PATCH 21/27] best-effort fix but testing dbmem sort order is a waste of time --- coderd/database/dbmem/dbmem.go | 5 +---- coderd/workspaces_test.go | 4 ++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 505c638096d16..29c109aec7285 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -7738,12 +7738,9 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. w2 := workspaces[j] // Order by: favorite first - if w1.FavoriteOf.Valid && !w2.FavoriteOf.Valid { + if w1.FavoriteOf == arg.OrderByFavorite { return true } - if !w1.FavoriteOf.Valid && w2.FavoriteOf.Valid { - return false - } // Order by: running w1IsRunning := isRunning(preloadedWorkspaceBuilds[w1.ID], preloadedProvisionerJobs[w1.ID]) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 3abb873eeccb0..05691da112188 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -479,6 +479,10 @@ func TestAdminViewAllWorkspaces(t *testing.T) { func TestWorkspacesSortOrder(t *testing.T) { t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("sort order is only tested on postgres") + } + client, db := coderdtest.NewWithDatabase(t, nil) firstUser := coderdtest.CreateFirstUser(t, client) secondUserClient, secondUser := coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, []string{"owner"}, func(r *codersdk.CreateUserRequest) { From f6e953173f0f3f72452880321073fe55e4805344 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 23 Jan 2024 10:57:21 +0000 Subject: [PATCH 22/27] requestor->requester --- coderd/workspaces.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 1097fe1ac12a7..3e3baead432d7 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1453,7 +1453,7 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa }, nil } -func convertWorkspaces(requestorID uuid.UUID, workspaces []database.Workspace, data workspaceData) ([]codersdk.Workspace, error) { +func convertWorkspaces(requesterID uuid.UUID, workspaces []database.Workspace, data workspaceData) ([]codersdk.Workspace, error) { buildByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceBuild{} for _, workspaceBuild := range data.builds { buildByWorkspaceID[workspaceBuild.WorkspaceID] = workspaceBuild @@ -1488,7 +1488,7 @@ func convertWorkspaces(requestorID uuid.UUID, workspaces []database.Workspace, d } apiWorkspaces = append(apiWorkspaces, convertWorkspace( - requestorID, + requesterID, workspace, build, template, @@ -1500,7 +1500,7 @@ func convertWorkspaces(requestorID uuid.UUID, workspaces []database.Workspace, d } func convertWorkspace( - requestorID uuid.UUID, + requesterID uuid.UUID, workspace database.Workspace, workspaceBuild codersdk.WorkspaceBuild, template database.Template, @@ -1532,7 +1532,7 @@ func convertWorkspace( } ttlMillis := convertWorkspaceTTLMillis(workspace.Ttl) - requestorFavorite := workspace.FavoriteOf.UUID == requestorID + requesterFavorite := workspace.FavoriteOf.UUID == requesterID return codersdk.Workspace{ ID: workspace.ID, @@ -1562,7 +1562,7 @@ func convertWorkspace( }, AutomaticUpdates: codersdk.AutomaticUpdates(workspace.AutomaticUpdates), AllowRenames: allowRenames, - Favorite: requestorFavorite, + Favorite: requesterFavorite, } } From 34f29023a05c39e18ed31f4b2d2f666d0a30a305 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 23 Jan 2024 10:58:17 +0000 Subject: [PATCH 23/27] fixup! remove unnecessary change to CreateFirstUser --- coderd/coderdtest/coderdtest.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 7ce1cce2c6022..91ff7e17538d9 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -600,8 +600,7 @@ var FirstUserParams = codersdk.CreateFirstUserRequest{ } // CreateFirstUser creates a user with preset credentials and authenticates -// with the passed in codersdk client. Optionally, pass a function to mutate -// the first user create request as required. +// with the passed in codersdk client. func CreateFirstUser(t testing.TB, client *codersdk.Client) codersdk.CreateFirstUserResponse { resp, err := client.CreateFirstUser(context.Background(), FirstUserParams) require.NoError(t, err) From 99c7f969d5240618e37345634bafffe954244d0c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 23 Jan 2024 11:07:24 +0000 Subject: [PATCH 24/27] remove unused resource --- coderd/apidoc/docs.go | 2 -- coderd/apidoc/swagger.json | 2 -- codersdk/audit.go | 29 +++++++++++++---------------- docs/api/schemas.md | 31 +++++++++++++++---------------- site/src/api/typesGenerated.ts | 2 -- 5 files changed, 28 insertions(+), 38 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d7d7d4abd033d..098ea767e4ffe 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10576,7 +10576,6 @@ const docTemplate = `{ "user", "workspace", "workspace_build", - "favorite_workspace", "git_ssh_key", "api_key", "group", @@ -10592,7 +10591,6 @@ const docTemplate = `{ "ResourceTypeUser", "ResourceTypeWorkspace", "ResourceTypeWorkspaceBuild", - "ResourceTypeFavoriteWorkspace", "ResourceTypeGitSSHKey", "ResourceTypeAPIKey", "ResourceTypeGroup", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 875d8010ec113..24bc5e29cc05c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9528,7 +9528,6 @@ "user", "workspace", "workspace_build", - "favorite_workspace", "git_ssh_key", "api_key", "group", @@ -9544,7 +9543,6 @@ "ResourceTypeUser", "ResourceTypeWorkspace", "ResourceTypeWorkspaceBuild", - "ResourceTypeFavoriteWorkspace", "ResourceTypeGitSSHKey", "ResourceTypeAPIKey", "ResourceTypeGroup", diff --git a/codersdk/audit.go b/codersdk/audit.go index 636bc66e83cff..c1ea077ec0831 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -14,20 +14,19 @@ import ( type ResourceType string const ( - ResourceTypeTemplate ResourceType = "template" - ResourceTypeTemplateVersion ResourceType = "template_version" - ResourceTypeUser ResourceType = "user" - ResourceTypeWorkspace ResourceType = "workspace" - ResourceTypeWorkspaceBuild ResourceType = "workspace_build" - ResourceTypeFavoriteWorkspace ResourceType = "favorite_workspace" - ResourceTypeGitSSHKey ResourceType = "git_ssh_key" - ResourceTypeAPIKey ResourceType = "api_key" - ResourceTypeGroup ResourceType = "group" - ResourceTypeLicense ResourceType = "license" - ResourceTypeConvertLogin ResourceType = "convert_login" - ResourceTypeHealthSettings ResourceType = "health_settings" - ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" - ResourceTypeOrganization ResourceType = "organization" + ResourceTypeTemplate ResourceType = "template" + ResourceTypeTemplateVersion ResourceType = "template_version" + ResourceTypeUser ResourceType = "user" + ResourceTypeWorkspace ResourceType = "workspace" + ResourceTypeWorkspaceBuild ResourceType = "workspace_build" + ResourceTypeGitSSHKey ResourceType = "git_ssh_key" + ResourceTypeAPIKey ResourceType = "api_key" + ResourceTypeGroup ResourceType = "group" + ResourceTypeLicense ResourceType = "license" + ResourceTypeConvertLogin ResourceType = "convert_login" + ResourceTypeHealthSettings ResourceType = "health_settings" + ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" + ResourceTypeOrganization ResourceType = "organization" ) func (r ResourceType) FriendlyString() string { @@ -44,8 +43,6 @@ func (r ResourceType) FriendlyString() string { // workspace builds have a unique friendly string // see coderd/audit.go:298 for explanation return "workspace" - case ResourceTypeFavoriteWorkspace: - return "favorite_workspace" case ResourceTypeGitSSHKey: return "git ssh key" case ResourceTypeAPIKey: diff --git a/docs/api/schemas.md b/docs/api/schemas.md index d1777194b91b1..8114d0750b65e 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4369,22 +4369,21 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value | -| -------------------- | -| `template` | -| `template_version` | -| `user` | -| `workspace` | -| `workspace_build` | -| `favorite_workspace` | -| `git_ssh_key` | -| `api_key` | -| `group` | -| `license` | -| `convert_login` | -| `health_settings` | -| `workspace_proxy` | -| `organization` | +| Value | +| ------------------ | +| `template` | +| `template_version` | +| `user` | +| `workspace` | +| `workspace_build` | +| `git_ssh_key` | +| `api_key` | +| `group` | +| `license` | +| `convert_login` | +| `health_settings` | +| `workspace_proxy` | +| `organization` | ## codersdk.Response diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6e712b8e419e3..eeab0f373bba6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2032,7 +2032,6 @@ export const RBACResources: RBACResource[] = [ export type ResourceType = | "api_key" | "convert_login" - | "favorite_workspace" | "git_ssh_key" | "group" | "health_settings" @@ -2047,7 +2046,6 @@ export type ResourceType = export const ResourceTypes: ResourceType[] = [ "api_key", "convert_login", - "favorite_workspace", "git_ssh_key", "group", "health_settings", From d53054622bbc4ad61a5d7a56d79bb331f171bc88 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 23 Jan 2024 11:57:42 +0000 Subject: [PATCH 25/27] fix sort ordering bug in dmem --- coderd/database/dbmem/dbmem.go | 11 +++++++---- coderd/workspaces_test.go | 8 ++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 29c109aec7285..b3163f93a5f6f 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -7738,9 +7738,12 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. w2 := workspaces[j] // Order by: favorite first - if w1.FavoriteOf == arg.OrderByFavorite { + if w1.FavoriteOf == arg.OrderByFavorite && w2.FavoriteOf != arg.OrderByFavorite { return true } + if w1.FavoriteOf != arg.OrderByFavorite && w2.FavoriteOf == arg.OrderByFavorite { + return false + } // Order by: running w1IsRunning := isRunning(preloadedWorkspaceBuilds[w1.ID], preloadedProvisionerJobs[w1.ID]) @@ -7755,12 +7758,12 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } // Order by: usernames - if w1.ID != w2.ID { - return sort.StringsAreSorted([]string{preloadedUsers[w1.ID].Username, preloadedUsers[w2.ID].Username}) + if strings.Compare(preloadedUsers[w1.ID].Username, preloadedUsers[w2.ID].Username) < 0 { + return true } // Order by: workspace names - return sort.StringsAreSorted([]string{w1.Name, w2.Name}) + return strings.Compare(w1.Name, w2.Name) < 0 }) beforePageCount := len(workspaces) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 05691da112188..39d566a4e764f 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -479,10 +479,6 @@ func TestAdminViewAllWorkspaces(t *testing.T) { func TestWorkspacesSortOrder(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("sort order is only tested on postgres") - } - client, db := coderdtest.NewWithDatabase(t, nil) firstUser := coderdtest.CreateFirstUser(t, client) secondUserClient, secondUser := coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, []string{"owner"}, func(r *codersdk.CreateUserRequest) { @@ -534,7 +530,7 @@ func TestWorkspacesSortOrder(t *testing.T) { // 2. Running workspaces // 3. Sort by usernames // 4. Sort by workspace names - require.Equal(t, expectedNames, actualNames) + assert.Equal(t, expectedNames, actualNames) // Once again but this time as a different user. This time we do not expect to see another // user's favorites first. @@ -561,7 +557,7 @@ func TestWorkspacesSortOrder(t *testing.T) { // 2. Running workspaces // 3. Sort by usernames // 4. Sort by workspace names - require.Equal(t, expectedNames, actualNames) + assert.Equal(t, expectedNames, actualNames) } func TestPostWorkspacesByOrganization(t *testing.T) { From 6392db9731149144f963a6953399c79e4af24ec4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 23 Jan 2024 12:49:10 +0000 Subject: [PATCH 26/27] add test case --- coderd/workspaces_test.go | 54 ++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 39d566a4e764f..228395f749b78 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2967,56 +2967,64 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ Auditor: auditRecorder, }) - owner = coderdtest.CreateFirstUser(t, client) - memberClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + owner = coderdtest.CreateFirstUser(t, client) + memberClient, member = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) // This will be our 'favorite' workspace - wsb = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: owner.UserID, OrganizationID: owner.OrganizationID}).Do() + wsb1 = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do() + wsb2 = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: owner.UserID, OrganizationID: owner.OrganizationID}).Do() ) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() // Initially, workspace should not be favored for member. - workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Favorite, "no favorites yet") - ws, err := client.Workspace(ctx, wsb.Workspace.ID) + ws, err := memberClient.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) require.False(t, ws.Favorite) // When user favorites workspace - err = client.FavoriteWorkspace(ctx, wsb.Workspace.ID) + err = memberClient.FavoriteWorkspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) // Then it should be favored for them. - workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 1) - require.True(t, workspaces.Workspaces[0].Favorite, "favorites should come first") - ws, err = client.Workspace(ctx, wsb.Workspace.ID) + ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) require.True(t, ws.Favorite) // When member unfavorites workspace - err = client.UnfavoriteWorkspace(ctx, wsb.Workspace.ID) + err = memberClient.UnfavoriteWorkspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) // Then it should no longer be favored - workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) require.NoError(t, err) - require.Len(t, workspaces.Workspaces, 1) - require.False(t, workspaces.Workspaces[0].Favorite, "no longer favorite") - ws, err = client.Workspace(ctx, wsb.Workspace.ID) - require.NoError(t, err) - require.False(t, ws.Favorite) + require.False(t, ws.Favorite, "no longer favorite") // Users without write access to the workspace should not be able to perform the above. - err = memberClient.FavoriteWorkspace(ctx, wsb.Workspace.ID) + err = memberClient.FavoriteWorkspace(ctx, wsb2.Workspace.ID) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) - err = memberClient.UnfavoriteWorkspace(ctx, wsb.Workspace.ID) + err = memberClient.UnfavoriteWorkspace(ctx, wsb2.Workspace.ID) require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + + // Owners should be able to favorite/unfavorite any workspace. + err = client.FavoriteWorkspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + ws, err = client.Workspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + require.False(t, ws.Favorite, "not owner's favorite") + ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + require.True(t, ws.Favorite) + + err = client.UnfavoriteWorkspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + ws, err = client.Workspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + require.False(t, ws.Favorite) + ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID) + require.NoError(t, err) + require.False(t, ws.Favorite) } From 9979c53f077676c65f12b09e1ff12b012fea6c8b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 23 Jan 2024 16:16:26 +0000 Subject: [PATCH 27/27] remove need for conditional ordering --- coderd/database/queries.sql.go | 6 +++--- coderd/database/queries/workspaces.sql | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 27c15df6ba8e8..a5ea4834cdeee 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11363,9 +11363,9 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY - CASE WHEN workspaces.favorite_of = $13 THEN - workspaces.favorite_of = $13 - END ASC, + -- COALESCE because the result of the comparison is NULL if not true + -- and this messes up the ordering. + COALESCE(workspaces.favorite_of = $13, false) DESC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 3697f0c8fc3cd..52218f97d9dfc 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -262,9 +262,9 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY - CASE WHEN workspaces.favorite_of = @order_by_favorite THEN - workspaces.favorite_of = @order_by_favorite - END ASC, + -- COALESCE because the result of the comparison is NULL if not true + -- and this messes up the ordering. + COALESCE(workspaces.favorite_of = @order_by_favorite, false) DESC, (latest_build.completed_at IS NOT NULL AND latest_build.canceled_at IS NULL AND latest_build.error IS NULL AND