diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 125cf4faa5ba1..2612083ba74dc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -13740,6 +13740,7 @@ const docTemplate = `{ "group", "group_member", "idpsync_settings", + "inbox_notification", "license", "notification_message", "notification_preference", @@ -13775,6 +13776,7 @@ const docTemplate = `{ "ResourceGroup", "ResourceGroupMember", "ResourceIdpsyncSettings", + "ResourceInboxNotification", "ResourceLicense", "ResourceNotificationMessage", "ResourceNotificationPreference", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 104d6fd70e077..27fea243afdd9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12429,6 +12429,7 @@ "group", "group_member", "idpsync_settings", + "inbox_notification", "license", "notification_message", "notification_preference", @@ -12464,6 +12465,7 @@ "ResourceGroup", "ResourceGroupMember", "ResourceIdpsyncSettings", + "ResourceInboxNotification", "ResourceLicense", "ResourceNotificationMessage", "ResourceNotificationPreference", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 877727069ab76..a39ba8d4172f0 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -281,6 +281,7 @@ var ( DisplayName: "Notifier", Site: rbac.Permissions(map[string][]policy.Action{ rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceInboxNotification.Type: {policy.ActionCreate}, }), Org: map[string][]rbac.Permission{}, User: []rbac.Permission{}, @@ -1126,6 +1127,14 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error { return q.db.CleanTailnetTunnels(ctx) } +func (q *querier) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceInboxNotification.WithOwner(userID.String())); err != nil { + return 0, err + } + return q.db.CountUnreadInboxNotificationsByUserID(ctx, userID) +} + +// TODO: Handle org scoped lookups func (q *querier) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { roleObject := rbac.ResourceAssignRole if arg.OrganizationID != uuid.Nil { @@ -1689,6 +1698,10 @@ func (q *querier) GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]dat return q.db.GetFileTemplates(ctx, fileID) } +func (q *querier) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetFilteredInboxNotificationsByUserID)(ctx, arg) +} + func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetGitSSHKey)(ctx, userID) } @@ -1748,6 +1761,14 @@ func (q *querier) GetHungProvisionerJobs(ctx context.Context, hungSince time.Tim return q.db.GetHungProvisionerJobs(ctx, hungSince) } +func (q *querier) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) { + return fetchWithAction(q.log, q.auth, policy.ActionRead, q.db.GetInboxNotificationByID)(ctx, id) +} + +func (q *querier) GetInboxNotificationsByUserID(ctx context.Context, userID database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetInboxNotificationsByUserID)(ctx, userID) +} + func (q *querier) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) { if _, err := fetch(q.log, q.auth, q.db.GetWorkspaceByID)(ctx, arg.WorkspaceID); err != nil { return database.JfrogXrayScan{}, err @@ -3079,6 +3100,10 @@ func (q *querier) InsertGroupMember(ctx context.Context, arg database.InsertGrou return update(q.log, q.auth, fetch, q.db.InsertGroupMember)(ctx, arg) } +func (q *querier) InsertInboxNotification(ctx context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) { + return insert(q.log, q.auth, rbac.ResourceInboxNotification.WithOwner(arg.UserID.String()), q.db.InsertInboxNotification)(ctx, arg) +} + func (q *querier) InsertLicense(ctx context.Context, arg database.InsertLicenseParams) (database.License, error) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceLicense); err != nil { return database.License{}, err @@ -3666,6 +3691,14 @@ func (q *querier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfte return q.db.UpdateInactiveUsersToDormant(ctx, lastSeenAfter) } +func (q *querier) UpdateInboxNotificationReadStatus(ctx context.Context, args database.UpdateInboxNotificationReadStatusParams) error { + fetchFunc := func(ctx context.Context, args database.UpdateInboxNotificationReadStatusParams) (database.InboxNotification, error) { + return q.db.GetInboxNotificationByID(ctx, args.ID) + } + + return update(q.log, q.auth, fetchFunc, q.db.UpdateInboxNotificationReadStatus)(ctx, args) +} + func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) { // Authorized fetch will check that the actor has read access to the org member since the org member is returned. member, err := database.ExpectOne(q.OrganizationMembers(ctx, database.OrganizationMembersParams{ diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 1f2ae5eca62c4..12d6d8804e3e4 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4466,6 +4466,141 @@ func (s *MethodTestSuite) TestNotifications() { Disableds: []bool{true, false}, }).Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionUpdate) })) + + s.Run("GetInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + + notifID := uuid.New() + + notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{ + ID: notifID, + UserID: u.ID, + TemplateID: notifications.TemplateWorkspaceAutoUpdated, + Title: "test title", + Content: "test content notification", + Icon: "https://coder.com/favicon.ico", + Actions: json.RawMessage("{}"), + }) + + check.Args(database.GetInboxNotificationsByUserIDParams{ + UserID: u.ID, + ReadStatus: database.InboxNotificationReadStatusAll, + }).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns([]database.InboxNotification{notif}) + })) + + s.Run("GetFilteredInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + + notifID := uuid.New() + + targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated} + + notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{ + ID: notifID, + UserID: u.ID, + TemplateID: notifications.TemplateWorkspaceAutoUpdated, + Targets: targets, + Title: "test title", + Content: "test content notification", + Icon: "https://coder.com/favicon.ico", + Actions: json.RawMessage("{}"), + }) + + check.Args(database.GetFilteredInboxNotificationsByUserIDParams{ + UserID: u.ID, + Templates: []uuid.UUID{notifications.TemplateWorkspaceAutoUpdated}, + Targets: []uuid.UUID{u.ID}, + ReadStatus: database.InboxNotificationReadStatusAll, + }).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns([]database.InboxNotification{notif}) + })) + + s.Run("GetInboxNotificationByID", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + + notifID := uuid.New() + + targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated} + + notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{ + ID: notifID, + UserID: u.ID, + TemplateID: notifications.TemplateWorkspaceAutoUpdated, + Targets: targets, + Title: "test title", + Content: "test content notification", + Icon: "https://coder.com/favicon.ico", + Actions: json.RawMessage("{}"), + }) + + check.Args(notifID).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns(notif) + })) + + s.Run("CountUnreadInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + + notifID := uuid.New() + + targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated} + + _ = dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{ + ID: notifID, + UserID: u.ID, + TemplateID: notifications.TemplateWorkspaceAutoUpdated, + Targets: targets, + Title: "test title", + Content: "test content notification", + Icon: "https://coder.com/favicon.ico", + Actions: json.RawMessage("{}"), + }) + + check.Args(u.ID).Asserts(rbac.ResourceInboxNotification.WithOwner(u.ID.String()), policy.ActionRead).Returns(int64(1)) + })) + + s.Run("InsertInboxNotification", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + + notifID := uuid.New() + + targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated} + + check.Args(database.InsertInboxNotificationParams{ + ID: notifID, + UserID: u.ID, + TemplateID: notifications.TemplateWorkspaceAutoUpdated, + Targets: targets, + Title: "test title", + Content: "test content notification", + Icon: "https://coder.com/favicon.ico", + Actions: json.RawMessage("{}"), + }).Asserts(rbac.ResourceInboxNotification.WithOwner(u.ID.String()), policy.ActionCreate) + })) + + s.Run("UpdateInboxNotificationReadStatus", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + + notifID := uuid.New() + + targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated} + readAt := dbtestutil.NowInDefaultTimezone() + + notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{ + ID: notifID, + UserID: u.ID, + TemplateID: notifications.TemplateWorkspaceAutoUpdated, + Targets: targets, + Title: "test title", + Content: "test content notification", + Icon: "https://coder.com/favicon.ico", + Actions: json.RawMessage("{}"), + }) + + notif.ReadAt = sql.NullTime{Time: readAt, Valid: true} + + check.Args(database.UpdateInboxNotificationReadStatusParams{ + ID: notifID, + ReadAt: sql.NullTime{Time: readAt, Valid: true}, + }).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionUpdate) + })) } func (s *MethodTestSuite) TestOAuth2ProviderApps() { diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 9c4ebbe8bb8ca..3810fcb5052cf 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -450,6 +450,22 @@ func OrganizationMember(t testing.TB, db database.Store, orig database.Organizat return mem } +func NotificationInbox(t testing.TB, db database.Store, orig database.InsertInboxNotificationParams) database.InboxNotification { + notification, err := db.InsertInboxNotification(genCtx, database.InsertInboxNotificationParams{ + ID: takeFirst(orig.ID, uuid.New()), + UserID: takeFirst(orig.UserID, uuid.New()), + TemplateID: takeFirst(orig.TemplateID, uuid.New()), + Targets: takeFirstSlice(orig.Targets, []uuid.UUID{}), + Title: takeFirst(orig.Title, testutil.GetRandomName(t)), + Content: takeFirst(orig.Content, testutil.GetRandomName(t)), + Icon: takeFirst(orig.Icon, ""), + Actions: orig.Actions, + CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), + }) + require.NoError(t, err, "insert notification") + return notification +} + func Group(t testing.TB, db database.Store, orig database.Group) database.Group { t.Helper() diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 6fbafa562d087..65d24bb3434c2 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -67,6 +67,7 @@ func New() database.Store { gitSSHKey: make([]database.GitSSHKey, 0), notificationMessages: make([]database.NotificationMessage, 0), notificationPreferences: make([]database.NotificationPreference, 0), + InboxNotification: make([]database.InboxNotification, 0), parameterSchemas: make([]database.ParameterSchema, 0), provisionerDaemons: make([]database.ProvisionerDaemon, 0), provisionerKeys: make([]database.ProvisionerKey, 0), @@ -206,6 +207,7 @@ type data struct { notificationMessages []database.NotificationMessage notificationPreferences []database.NotificationPreference notificationReportGeneratorLogs []database.NotificationReportGeneratorLog + InboxNotification []database.InboxNotification oauth2ProviderApps []database.OAuth2ProviderApp oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret oauth2ProviderAppCodes []database.OAuth2ProviderAppCode @@ -1606,6 +1608,26 @@ func (*FakeQuerier) CleanTailnetTunnels(context.Context) error { return ErrUnimplemented } +func (q *FakeQuerier) CountUnreadInboxNotificationsByUserID(_ context.Context, userID uuid.UUID) (int64, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + var count int64 + for _, notification := range q.InboxNotification { + if notification.UserID != userID { + continue + } + + if notification.ReadAt.Valid { + continue + } + + count++ + } + + return count, nil +} + func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -3130,6 +3152,45 @@ func (q *FakeQuerier) GetFileTemplates(_ context.Context, id uuid.UUID) ([]datab return rows, nil } +func (q *FakeQuerier) GetFilteredInboxNotificationsByUserID(_ context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + notifications := make([]database.InboxNotification, 0) + for _, notification := range q.InboxNotification { + if notification.UserID == arg.UserID { + for _, template := range arg.Templates { + templateFound := false + if notification.TemplateID == template { + templateFound = true + } + + if !templateFound { + continue + } + } + + for _, target := range arg.Targets { + isFound := false + for _, insertedTarget := range notification.Targets { + if insertedTarget == target { + isFound = true + break + } + } + + if !isFound { + continue + } + + notifications = append(notifications, notification) + } + } + } + + return notifications, nil +} + func (q *FakeQuerier) GetGitSSHKey(_ context.Context, userID uuid.UUID) (database.GitSSHKey, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -3328,6 +3389,33 @@ func (q *FakeQuerier) GetHungProvisionerJobs(_ context.Context, hungSince time.T return hungJobs, nil } +func (q *FakeQuerier) GetInboxNotificationByID(_ context.Context, id uuid.UUID) (database.InboxNotification, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, notification := range q.InboxNotification { + if notification.ID == id { + return notification, nil + } + } + + return database.InboxNotification{}, sql.ErrNoRows +} + +func (q *FakeQuerier) GetInboxNotificationsByUserID(_ context.Context, params database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + notifications := make([]database.InboxNotification, 0) + for _, notification := range q.InboxNotification { + if notification.UserID == params.UserID { + notifications = append(notifications, notification) + } + } + + return notifications, nil +} + func (q *FakeQuerier) GetJFrogXrayScanByWorkspaceAndAgentID(_ context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) { err := validateDatabaseType(arg) if err != nil { @@ -7965,6 +8053,30 @@ func (q *FakeQuerier) InsertGroupMember(_ context.Context, arg database.InsertGr return nil } +func (q *FakeQuerier) InsertInboxNotification(_ context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) { + if err := validateDatabaseType(arg); err != nil { + return database.InboxNotification{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + notification := database.InboxNotification{ + ID: arg.ID, + UserID: arg.UserID, + TemplateID: arg.TemplateID, + Targets: arg.Targets, + Title: arg.Title, + Content: arg.Content, + Icon: arg.Icon, + Actions: arg.Actions, + CreatedAt: time.Now(), + } + + q.InboxNotification = append(q.InboxNotification, notification) + return notification, nil +} + func (q *FakeQuerier) InsertLicense( _ context.Context, arg database.InsertLicenseParams, ) (database.License, error) { @@ -9679,6 +9791,24 @@ func (q *FakeQuerier) UpdateInactiveUsersToDormant(_ context.Context, params dat return updated, nil } +func (q *FakeQuerier) UpdateInboxNotificationReadStatus(_ context.Context, arg database.UpdateInboxNotificationReadStatusParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i := range q.InboxNotification { + if q.InboxNotification[i].ID == arg.ID { + q.InboxNotification[i].ReadAt = arg.ReadAt + } + } + + return nil +} + func (q *FakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) { if err := validateDatabaseType(arg); err != nil { return database.OrganizationMember{}, err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 31fbcced1b7f2..d05ec5f5acdf9 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -178,6 +178,13 @@ func (m queryMetricsStore) CleanTailnetTunnels(ctx context.Context) error { return r0 } +func (m queryMetricsStore) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) { + start := time.Now() + r0, r1 := m.s.CountUnreadInboxNotificationsByUserID(ctx, userID) + m.queryLatencies.WithLabelValues("CountUnreadInboxNotificationsByUserID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { start := time.Now() r0, r1 := m.s.CustomRoles(ctx, arg) @@ -710,6 +717,13 @@ func (m queryMetricsStore) GetFileTemplates(ctx context.Context, fileID uuid.UUI return rows, err } +func (m queryMetricsStore) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) { + start := time.Now() + r0, r1 := m.s.GetFilteredInboxNotificationsByUserID(ctx, arg) + m.queryLatencies.WithLabelValues("GetFilteredInboxNotificationsByUserID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { start := time.Now() key, err := m.s.GetGitSSHKey(ctx, userID) @@ -773,6 +787,20 @@ func (m queryMetricsStore) GetHungProvisionerJobs(ctx context.Context, hungSince return jobs, err } +func (m queryMetricsStore) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) { + start := time.Now() + r0, r1 := m.s.GetInboxNotificationByID(ctx, id) + m.queryLatencies.WithLabelValues("GetInboxNotificationByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) GetInboxNotificationsByUserID(ctx context.Context, userID database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) { + start := time.Now() + r0, r1 := m.s.GetInboxNotificationsByUserID(ctx, userID) + m.queryLatencies.WithLabelValues("GetInboxNotificationsByUserID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) { start := time.Now() r0, r1 := m.s.GetJFrogXrayScanByWorkspaceAndAgentID(ctx, arg) @@ -1879,6 +1907,13 @@ func (m queryMetricsStore) InsertGroupMember(ctx context.Context, arg database.I return err } +func (m queryMetricsStore) InsertInboxNotification(ctx context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) { + start := time.Now() + r0, r1 := m.s.InsertInboxNotification(ctx, arg) + m.queryLatencies.WithLabelValues("InsertInboxNotification").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) InsertLicense(ctx context.Context, arg database.InsertLicenseParams) (database.License, error) { start := time.Now() license, err := m.s.InsertLicense(ctx, arg) @@ -2334,6 +2369,13 @@ func (m queryMetricsStore) UpdateInactiveUsersToDormant(ctx context.Context, las return r0, r1 } +func (m queryMetricsStore) UpdateInboxNotificationReadStatus(ctx context.Context, arg database.UpdateInboxNotificationReadStatusParams) error { + start := time.Now() + r0 := m.s.UpdateInboxNotificationReadStatus(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateInboxNotificationReadStatus").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) { start := time.Now() member, err := m.s.UpdateMemberRoles(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index f92bbf13246d7..39f148d90e20e 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -232,6 +232,21 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanTailnetTunnels", reflect.TypeOf((*MockStore)(nil).CleanTailnetTunnels), ctx) } +// CountUnreadInboxNotificationsByUserID mocks base method. +func (m *MockStore) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountUnreadInboxNotificationsByUserID", ctx, userID) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountUnreadInboxNotificationsByUserID indicates an expected call of CountUnreadInboxNotificationsByUserID. +func (mr *MockStoreMockRecorder) CountUnreadInboxNotificationsByUserID(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountUnreadInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).CountUnreadInboxNotificationsByUserID), ctx, userID) +} + // CustomRoles mocks base method. func (m *MockStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { m.ctrl.T.Helper() @@ -1417,6 +1432,21 @@ func (mr *MockStoreMockRecorder) GetFileTemplates(ctx, fileID any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileTemplates", reflect.TypeOf((*MockStore)(nil).GetFileTemplates), ctx, fileID) } +// GetFilteredInboxNotificationsByUserID mocks base method. +func (m *MockStore) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg database.GetFilteredInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetFilteredInboxNotificationsByUserID", ctx, arg) + ret0, _ := ret[0].([]database.InboxNotification) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetFilteredInboxNotificationsByUserID indicates an expected call of GetFilteredInboxNotificationsByUserID. +func (mr *MockStoreMockRecorder) GetFilteredInboxNotificationsByUserID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFilteredInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).GetFilteredInboxNotificationsByUserID), ctx, arg) +} + // GetGitSSHKey mocks base method. func (m *MockStore) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { m.ctrl.T.Helper() @@ -1552,6 +1582,36 @@ func (mr *MockStoreMockRecorder) GetHungProvisionerJobs(ctx, updatedAt any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHungProvisionerJobs", reflect.TypeOf((*MockStore)(nil).GetHungProvisionerJobs), ctx, updatedAt) } +// GetInboxNotificationByID mocks base method. +func (m *MockStore) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (database.InboxNotification, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInboxNotificationByID", ctx, id) + ret0, _ := ret[0].(database.InboxNotification) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetInboxNotificationByID indicates an expected call of GetInboxNotificationByID. +func (mr *MockStoreMockRecorder) GetInboxNotificationByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInboxNotificationByID", reflect.TypeOf((*MockStore)(nil).GetInboxNotificationByID), ctx, id) +} + +// GetInboxNotificationsByUserID mocks base method. +func (m *MockStore) GetInboxNotificationsByUserID(ctx context.Context, arg database.GetInboxNotificationsByUserIDParams) ([]database.InboxNotification, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInboxNotificationsByUserID", ctx, arg) + ret0, _ := ret[0].([]database.InboxNotification) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetInboxNotificationsByUserID indicates an expected call of GetInboxNotificationsByUserID. +func (mr *MockStoreMockRecorder) GetInboxNotificationsByUserID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).GetInboxNotificationsByUserID), ctx, arg) +} + // GetJFrogXrayScanByWorkspaceAndAgentID mocks base method. func (m *MockStore) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg database.GetJFrogXrayScanByWorkspaceAndAgentIDParams) (database.JfrogXrayScan, error) { m.ctrl.T.Helper() @@ -3962,6 +4022,21 @@ func (mr *MockStoreMockRecorder) InsertGroupMember(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertGroupMember", reflect.TypeOf((*MockStore)(nil).InsertGroupMember), ctx, arg) } +// InsertInboxNotification mocks base method. +func (m *MockStore) InsertInboxNotification(ctx context.Context, arg database.InsertInboxNotificationParams) (database.InboxNotification, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertInboxNotification", ctx, arg) + ret0, _ := ret[0].(database.InboxNotification) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertInboxNotification indicates an expected call of InsertInboxNotification. +func (mr *MockStoreMockRecorder) InsertInboxNotification(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertInboxNotification", reflect.TypeOf((*MockStore)(nil).InsertInboxNotification), ctx, arg) +} + // InsertLicense mocks base method. func (m *MockStore) InsertLicense(ctx context.Context, arg database.InsertLicenseParams) (database.License, error) { m.ctrl.T.Helper() @@ -4951,6 +5026,20 @@ func (mr *MockStoreMockRecorder) UpdateInactiveUsersToDormant(ctx, arg any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateInactiveUsersToDormant", reflect.TypeOf((*MockStore)(nil).UpdateInactiveUsersToDormant), ctx, arg) } +// UpdateInboxNotificationReadStatus mocks base method. +func (m *MockStore) UpdateInboxNotificationReadStatus(ctx context.Context, arg database.UpdateInboxNotificationReadStatusParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateInboxNotificationReadStatus", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateInboxNotificationReadStatus indicates an expected call of UpdateInboxNotificationReadStatus. +func (mr *MockStoreMockRecorder) UpdateInboxNotificationReadStatus(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateInboxNotificationReadStatus", reflect.TypeOf((*MockStore)(nil).UpdateInboxNotificationReadStatus), ctx, arg) +} + // UpdateMemberRoles mocks base method. func (m *MockStore) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index e05d3a06d31f5..c35a30ae2d866 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -66,6 +66,12 @@ CREATE TYPE group_source AS ENUM ( 'oidc' ); +CREATE TYPE inbox_notification_read_status AS ENUM ( + 'all', + 'unread', + 'read' +); + CREATE TYPE log_level AS ENUM ( 'trace', 'debug', @@ -899,6 +905,19 @@ CREATE VIEW group_members_expanded AS COMMENT ON VIEW group_members_expanded IS 'Joins group members with user information, organization ID, group name. Includes both regular group members and organization members (as part of the "Everyone" group).'; +CREATE TABLE inbox_notifications ( + id uuid NOT NULL, + user_id uuid NOT NULL, + template_id uuid NOT NULL, + targets uuid[], + title text NOT NULL, + content text NOT NULL, + icon text NOT NULL, + actions jsonb NOT NULL, + read_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + CREATE TABLE jfrog_xray_scans ( agent_id uuid NOT NULL, workspace_id uuid NOT NULL, @@ -2048,6 +2067,9 @@ ALTER TABLE ONLY groups ALTER TABLE ONLY groups ADD CONSTRAINT groups_pkey PRIMARY KEY (id); +ALTER TABLE ONLY inbox_notifications + ADD CONSTRAINT inbox_notifications_pkey PRIMARY KEY (id); + ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_pkey PRIMARY KEY (agent_id, workspace_id); @@ -2278,6 +2300,10 @@ CREATE INDEX idx_custom_roles_id ON custom_roles USING btree (id); CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); +CREATE INDEX idx_inbox_notifications_user_id_read_at ON inbox_notifications USING btree (user_id, read_at); + +CREATE INDEX idx_inbox_notifications_user_id_template_id_targets ON inbox_notifications USING btree (user_id, template_id, targets); + CREATE INDEX idx_notification_messages_status ON notification_messages USING btree (status); CREATE INDEX idx_organization_member_organization_id_uuid ON organization_members USING btree (organization_id); @@ -2474,6 +2500,12 @@ ALTER TABLE ONLY group_members ALTER TABLE ONLY groups ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE ONLY inbox_notifications + ADD CONSTRAINT inbox_notifications_template_id_fkey FOREIGN KEY (template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; + +ALTER TABLE ONLY inbox_notifications + ADD CONSTRAINT inbox_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 66c379a749e01..525d240f25267 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -14,6 +14,8 @@ const ( ForeignKeyGroupMembersGroupID ForeignKeyConstraint = "group_members_group_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; ForeignKeyGroupMembersUserID ForeignKeyConstraint = "group_members_user_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyGroupsOrganizationID ForeignKeyConstraint = "groups_organization_id_fkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyInboxNotificationsTemplateID ForeignKeyConstraint = "inbox_notifications_template_id_fkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_template_id_fkey FOREIGN KEY (template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; + ForeignKeyInboxNotificationsUserID ForeignKeyConstraint = "inbox_notifications_user_id_fkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyJfrogXrayScansAgentID ForeignKeyConstraint = "jfrog_xray_scans_agent_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyJfrogXrayScansWorkspaceID ForeignKeyConstraint = "jfrog_xray_scans_workspace_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; ForeignKeyNotificationMessagesNotificationTemplateID ForeignKeyConstraint = "notification_messages_notification_template_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000297_notifications_inbox.down.sql b/coderd/database/migrations/000297_notifications_inbox.down.sql new file mode 100644 index 0000000000000..9d39b226c8a2c --- /dev/null +++ b/coderd/database/migrations/000297_notifications_inbox.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS inbox_notifications; + +DROP TYPE IF EXISTS inbox_notification_read_status; diff --git a/coderd/database/migrations/000297_notifications_inbox.up.sql b/coderd/database/migrations/000297_notifications_inbox.up.sql new file mode 100644 index 0000000000000..c3754c53674df --- /dev/null +++ b/coderd/database/migrations/000297_notifications_inbox.up.sql @@ -0,0 +1,17 @@ +CREATE TYPE inbox_notification_read_status AS ENUM ('all', 'unread', 'read'); + +CREATE TABLE inbox_notifications ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + template_id UUID NOT NULL REFERENCES notification_templates(id) ON DELETE CASCADE, + targets UUID[], + title TEXT NOT NULL, + content TEXT NOT NULL, + icon TEXT NOT NULL, + actions JSONB NOT NULL, + read_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_inbox_notifications_user_id_read_at ON inbox_notifications(user_id, read_at); +CREATE INDEX idx_inbox_notifications_user_id_template_id_targets ON inbox_notifications(user_id, template_id, targets); diff --git a/coderd/database/migrations/testdata/fixtures/000297_notifications_inbox.up.sql b/coderd/database/migrations/testdata/fixtures/000297_notifications_inbox.up.sql new file mode 100644 index 0000000000000..fb4cecf096eae --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000297_notifications_inbox.up.sql @@ -0,0 +1,25 @@ +INSERT INTO + inbox_notifications ( + id, + user_id, + template_id, + targets, + title, + content, + icon, + actions, + read_at, + created_at + ) + VALUES ( + '68b396aa-7f53-4bf1-b8d8-4cbf5fa244e5', -- uuid + '5755e622-fadd-44ca-98da-5df070491844', -- uuid + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', -- uuid + ARRAY[]::UUID[], -- uuid[] + 'Test Notification', + 'This is a test notification', + 'https://test.coder.com/favicon.ico', + '{}', + '2025-01-01 00:00:00', + '2025-01-01 00:00:00' + ); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 803cfbf01ced2..d9013b1f08c0c 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -168,6 +168,12 @@ func (TemplateVersion) RBACObject(template Template) rbac.Object { return template.RBACObject() } +func (i InboxNotification) RBACObject() rbac.Object { + return rbac.ResourceInboxNotification. + WithID(i.ID). + WithOwner(i.UserID.String()) +} + // RBACObjectNoTemplate is for orphaned template versions. func (v TemplateVersion) RBACObjectNoTemplate() rbac.Object { return rbac.ResourceTemplate.InOrg(v.OrganizationID) diff --git a/coderd/database/models.go b/coderd/database/models.go index 4e3353f844a02..3e0f59e6e9391 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -543,6 +543,67 @@ func AllGroupSourceValues() []GroupSource { } } +type InboxNotificationReadStatus string + +const ( + InboxNotificationReadStatusAll InboxNotificationReadStatus = "all" + InboxNotificationReadStatusUnread InboxNotificationReadStatus = "unread" + InboxNotificationReadStatusRead InboxNotificationReadStatus = "read" +) + +func (e *InboxNotificationReadStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = InboxNotificationReadStatus(s) + case string: + *e = InboxNotificationReadStatus(s) + default: + return fmt.Errorf("unsupported scan type for InboxNotificationReadStatus: %T", src) + } + return nil +} + +type NullInboxNotificationReadStatus struct { + InboxNotificationReadStatus InboxNotificationReadStatus `json:"inbox_notification_read_status"` + Valid bool `json:"valid"` // Valid is true if InboxNotificationReadStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullInboxNotificationReadStatus) Scan(value interface{}) error { + if value == nil { + ns.InboxNotificationReadStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.InboxNotificationReadStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullInboxNotificationReadStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.InboxNotificationReadStatus), nil +} + +func (e InboxNotificationReadStatus) Valid() bool { + switch e { + case InboxNotificationReadStatusAll, + InboxNotificationReadStatusUnread, + InboxNotificationReadStatusRead: + return true + } + return false +} + +func AllInboxNotificationReadStatusValues() []InboxNotificationReadStatus { + return []InboxNotificationReadStatus{ + InboxNotificationReadStatusAll, + InboxNotificationReadStatusUnread, + InboxNotificationReadStatusRead, + } +} + type LogLevel string const ( @@ -2557,6 +2618,19 @@ type GroupMemberTable struct { GroupID uuid.UUID `db:"group_id" json:"group_id"` } +type InboxNotification struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Targets []uuid.UUID `db:"targets" json:"targets"` + Title string `db:"title" json:"title"` + Content string `db:"content" json:"content"` + Icon string `db:"icon" json:"icon"` + Actions json.RawMessage `db:"actions" json:"actions"` + ReadAt sql.NullTime `db:"read_at" json:"read_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + type JfrogXrayScan struct { AgentID uuid.UUID `db:"agent_id" json:"agent_id"` WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 527ee955819d8..6bae27ec1f3d4 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -63,6 +63,7 @@ type sqlcQuerier interface { CleanTailnetCoordinators(ctx context.Context) error CleanTailnetLostPeers(ctx context.Context) error CleanTailnetTunnels(ctx context.Context) error + CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error @@ -158,6 +159,14 @@ type sqlcQuerier interface { GetFileByID(ctx context.Context, id uuid.UUID) (File, error) // Get all templates that use a file. GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]GetFileTemplatesRow, error) + // Fetches inbox notifications for a user filtered by templates and targets + // param user_id: The user ID + // param templates: The template IDs to filter by - the template_id = ANY(@templates::UUID[]) condition checks if the template_id is in the @templates array + // param targets: The target IDs to filter by - the targets @> COALESCE(@targets, ARRAY[]::UUID[]) condition checks if the targets array (from the DB) contains all the elements in the @targets array + // param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ' + // param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value + // param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25 + GetFilteredInboxNotificationsByUserID(ctx context.Context, arg GetFilteredInboxNotificationsByUserIDParams) ([]InboxNotification, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) @@ -170,6 +179,13 @@ type sqlcQuerier interface { GetGroups(ctx context.Context, arg GetGroupsParams) ([]GetGroupsRow, error) GetHealthSettings(ctx context.Context) (string, error) GetHungProvisionerJobs(ctx context.Context, updatedAt time.Time) ([]ProvisionerJob, error) + GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (InboxNotification, error) + // Fetches inbox notifications for a user filtered by templates and targets + // param user_id: The user ID + // param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ' + // param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value + // param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25 + GetInboxNotificationsByUserID(ctx context.Context, arg GetInboxNotificationsByUserIDParams) ([]InboxNotification, error) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg GetJFrogXrayScanByWorkspaceAndAgentIDParams) (JfrogXrayScan, error) GetLastUpdateCheck(ctx context.Context) (string, error) GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error) @@ -396,6 +412,7 @@ type sqlcQuerier interface { InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error + InsertInboxNotification(ctx context.Context, arg InsertInboxNotificationParams) (InboxNotification, error) InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) InsertMemoryResourceMonitor(ctx context.Context, arg InsertMemoryResourceMonitorParams) (WorkspaceAgentMemoryResourceMonitor, error) // Inserts any group by name that does not exist. All new groups are given @@ -479,6 +496,7 @@ type sqlcQuerier interface { UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error) + UpdateInboxNotificationReadStatus(ctx context.Context, arg UpdateInboxNotificationReadStatusParams) error UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) UpdateMemoryResourceMonitor(ctx context.Context, arg UpdateMemoryResourceMonitorParams) error UpdateNotificationTemplateMethodByID(ctx context.Context, arg UpdateNotificationTemplateMethodByIDParams) (NotificationTemplate, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 56ee5cfa3a9af..0891bc8c9fcc6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4298,6 +4298,243 @@ func (q *sqlQuerier) UpsertNotificationReportGeneratorLog(ctx context.Context, a return err } +const countUnreadInboxNotificationsByUserID = `-- name: CountUnreadInboxNotificationsByUserID :one +SELECT COUNT(*) FROM inbox_notifications WHERE user_id = $1 AND read_at IS NULL +` + +func (q *sqlQuerier) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) { + row := q.db.QueryRowContext(ctx, countUnreadInboxNotificationsByUserID, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const getFilteredInboxNotificationsByUserID = `-- name: GetFilteredInboxNotificationsByUserID :many +SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE + user_id = $1 AND + template_id = ANY($2::UUID[]) AND + targets @> COALESCE($3, ARRAY[]::UUID[]) AND + ($4::inbox_notification_read_status = 'all' OR ($4::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR ($4::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND + ($5::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < $5::TIMESTAMPTZ) + ORDER BY created_at DESC + LIMIT (COALESCE(NULLIF($6 :: INT, 0), 25)) +` + +type GetFilteredInboxNotificationsByUserIDParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Templates []uuid.UUID `db:"templates" json:"templates"` + Targets []uuid.UUID `db:"targets" json:"targets"` + ReadStatus InboxNotificationReadStatus `db:"read_status" json:"read_status"` + CreatedAtOpt time.Time `db:"created_at_opt" json:"created_at_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +// Fetches inbox notifications for a user filtered by templates and targets +// param user_id: The user ID +// param templates: The template IDs to filter by - the template_id = ANY(@templates::UUID[]) condition checks if the template_id is in the @templates array +// param targets: The target IDs to filter by - the targets @> COALESCE(@targets, ARRAY[]::UUID[]) condition checks if the targets array (from the DB) contains all the elements in the @targets array +// param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ' +// param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value +// param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25 +func (q *sqlQuerier) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg GetFilteredInboxNotificationsByUserIDParams) ([]InboxNotification, error) { + rows, err := q.db.QueryContext(ctx, getFilteredInboxNotificationsByUserID, + arg.UserID, + pq.Array(arg.Templates), + pq.Array(arg.Targets), + arg.ReadStatus, + arg.CreatedAtOpt, + arg.LimitOpt, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []InboxNotification + for rows.Next() { + var i InboxNotification + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.TemplateID, + pq.Array(&i.Targets), + &i.Title, + &i.Content, + &i.Icon, + &i.Actions, + &i.ReadAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getInboxNotificationByID = `-- name: GetInboxNotificationByID :one +SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE id = $1 +` + +func (q *sqlQuerier) GetInboxNotificationByID(ctx context.Context, id uuid.UUID) (InboxNotification, error) { + row := q.db.QueryRowContext(ctx, getInboxNotificationByID, id) + var i InboxNotification + err := row.Scan( + &i.ID, + &i.UserID, + &i.TemplateID, + pq.Array(&i.Targets), + &i.Title, + &i.Content, + &i.Icon, + &i.Actions, + &i.ReadAt, + &i.CreatedAt, + ) + return i, err +} + +const getInboxNotificationsByUserID = `-- name: GetInboxNotificationsByUserID :many +SELECT id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at FROM inbox_notifications WHERE + user_id = $1 AND + ($2::inbox_notification_read_status = 'all' OR ($2::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR ($2::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND + ($3::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < $3::TIMESTAMPTZ) + ORDER BY created_at DESC + LIMIT (COALESCE(NULLIF($4 :: INT, 0), 25)) +` + +type GetInboxNotificationsByUserIDParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + ReadStatus InboxNotificationReadStatus `db:"read_status" json:"read_status"` + CreatedAtOpt time.Time `db:"created_at_opt" json:"created_at_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +// Fetches inbox notifications for a user filtered by templates and targets +// param user_id: The user ID +// param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ' +// param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value +// param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25 +func (q *sqlQuerier) GetInboxNotificationsByUserID(ctx context.Context, arg GetInboxNotificationsByUserIDParams) ([]InboxNotification, error) { + rows, err := q.db.QueryContext(ctx, getInboxNotificationsByUserID, + arg.UserID, + arg.ReadStatus, + arg.CreatedAtOpt, + arg.LimitOpt, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []InboxNotification + for rows.Next() { + var i InboxNotification + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.TemplateID, + pq.Array(&i.Targets), + &i.Title, + &i.Content, + &i.Icon, + &i.Actions, + &i.ReadAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertInboxNotification = `-- name: InsertInboxNotification :one +INSERT INTO + inbox_notifications ( + id, + user_id, + template_id, + targets, + title, + content, + icon, + actions, + created_at + ) +VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, user_id, template_id, targets, title, content, icon, actions, read_at, created_at +` + +type InsertInboxNotificationParams struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Targets []uuid.UUID `db:"targets" json:"targets"` + Title string `db:"title" json:"title"` + Content string `db:"content" json:"content"` + Icon string `db:"icon" json:"icon"` + Actions json.RawMessage `db:"actions" json:"actions"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +func (q *sqlQuerier) InsertInboxNotification(ctx context.Context, arg InsertInboxNotificationParams) (InboxNotification, error) { + row := q.db.QueryRowContext(ctx, insertInboxNotification, + arg.ID, + arg.UserID, + arg.TemplateID, + pq.Array(arg.Targets), + arg.Title, + arg.Content, + arg.Icon, + arg.Actions, + arg.CreatedAt, + ) + var i InboxNotification + err := row.Scan( + &i.ID, + &i.UserID, + &i.TemplateID, + pq.Array(&i.Targets), + &i.Title, + &i.Content, + &i.Icon, + &i.Actions, + &i.ReadAt, + &i.CreatedAt, + ) + return i, err +} + +const updateInboxNotificationReadStatus = `-- name: UpdateInboxNotificationReadStatus :exec +UPDATE + inbox_notifications +SET + read_at = $1 +WHERE + id = $2 +` + +type UpdateInboxNotificationReadStatusParams struct { + ReadAt sql.NullTime `db:"read_at" json:"read_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateInboxNotificationReadStatus(ctx context.Context, arg UpdateInboxNotificationReadStatusParams) error { + _, err := q.db.ExecContext(ctx, updateInboxNotificationReadStatus, arg.ReadAt, arg.ID) + return err +} + const deleteOAuth2ProviderAppByID = `-- name: DeleteOAuth2ProviderAppByID :exec DELETE FROM oauth2_provider_apps WHERE id = $1 ` diff --git a/coderd/database/queries/notificationsinbox.sql b/coderd/database/queries/notificationsinbox.sql new file mode 100644 index 0000000000000..cdaf1cf78cb7f --- /dev/null +++ b/coderd/database/queries/notificationsinbox.sql @@ -0,0 +1,59 @@ +-- name: GetInboxNotificationsByUserID :many +-- Fetches inbox notifications for a user filtered by templates and targets +-- param user_id: The user ID +-- param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ' +-- param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value +-- param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25 +SELECT * FROM inbox_notifications WHERE + user_id = @user_id AND + (@read_status::inbox_notification_read_status = 'all' OR (@read_status::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR (@read_status::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND + (@created_at_opt::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < @created_at_opt::TIMESTAMPTZ) + ORDER BY created_at DESC + LIMIT (COALESCE(NULLIF(@limit_opt :: INT, 0), 25)); + +-- name: GetFilteredInboxNotificationsByUserID :many +-- Fetches inbox notifications for a user filtered by templates and targets +-- param user_id: The user ID +-- param templates: The template IDs to filter by - the template_id = ANY(@templates::UUID[]) condition checks if the template_id is in the @templates array +-- param targets: The target IDs to filter by - the targets @> COALESCE(@targets, ARRAY[]::UUID[]) condition checks if the targets array (from the DB) contains all the elements in the @targets array +-- param read_status: The read status to filter by - can be any of 'ALL', 'UNREAD', 'READ' +-- param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value +-- param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25 +SELECT * FROM inbox_notifications WHERE + user_id = @user_id AND + template_id = ANY(@templates::UUID[]) AND + targets @> COALESCE(@targets, ARRAY[]::UUID[]) AND + (@read_status::inbox_notification_read_status = 'all' OR (@read_status::inbox_notification_read_status = 'unread' AND read_at IS NULL) OR (@read_status::inbox_notification_read_status = 'read' AND read_at IS NOT NULL)) AND + (@created_at_opt::TIMESTAMPTZ = '0001-01-01 00:00:00Z' OR created_at < @created_at_opt::TIMESTAMPTZ) + ORDER BY created_at DESC + LIMIT (COALESCE(NULLIF(@limit_opt :: INT, 0), 25)); + +-- name: GetInboxNotificationByID :one +SELECT * FROM inbox_notifications WHERE id = $1; + +-- name: CountUnreadInboxNotificationsByUserID :one +SELECT COUNT(*) FROM inbox_notifications WHERE user_id = $1 AND read_at IS NULL; + +-- name: InsertInboxNotification :one +INSERT INTO + inbox_notifications ( + id, + user_id, + template_id, + targets, + title, + content, + icon, + actions, + created_at + ) +VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; + +-- name: UpdateInboxNotificationReadStatus :exec +UPDATE + inbox_notifications +SET + read_at = $1 +WHERE + id = $2; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index db68849777247..eb61e2f39a2c8 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -21,6 +21,7 @@ const ( UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); UniqueGroupsPkey UniqueConstraint = "groups_pkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_pkey PRIMARY KEY (id); + UniqueInboxNotificationsPkey UniqueConstraint = "inbox_notifications_pkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_pkey PRIMARY KEY (id); UniqueJfrogXrayScansPkey UniqueConstraint = "jfrog_xray_scans_pkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_pkey PRIMARY KEY (agent_id, workspace_id); UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 86faa5f9456dc..47b8c58a6f32b 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -119,6 +119,15 @@ var ( Type: "idpsync_settings", } + // ResourceInboxNotification + // Valid Actions + // - "ActionCreate" :: create inbox notifications + // - "ActionRead" :: read inbox notifications + // - "ActionUpdate" :: update inbox notifications + ResourceInboxNotification = Object{ + Type: "inbox_notification", + } + // ResourceLicense // Valid Actions // - "ActionCreate" :: create a license @@ -334,6 +343,7 @@ func AllResources() []Objecter { ResourceGroup, ResourceGroupMember, ResourceIdpsyncSettings, + ResourceInboxNotification, ResourceLicense, ResourceNotificationMessage, ResourceNotificationPreference, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 0988401e3849c..7f9736eaad751 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -280,6 +280,13 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionUpdate: actDef("update notification preferences"), }, }, + "inbox_notification": { + Actions: map[Action]ActionDefinition{ + ActionCreate: actDef("create inbox notifications"), + ActionRead: actDef("read inbox notifications"), + ActionUpdate: actDef("update inbox notifications"), + }, + }, "crypto_key": { Actions: map[Action]ActionDefinition{ ActionRead: actDef("read crypto keys"), diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 51eb15def9739..dd5c090786b0e 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -365,6 +365,17 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, setOrgNotMe, templateAdmin, userAdmin}, }, }, + { + Name: "InboxNotification", + Actions: []policy.Action{ + policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, + }, + Resource: rbac.ResourceInboxNotification.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgMemberMe, orgAdmin}, + false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, templateAdmin, userAdmin, memberMe}, + }, + }, { Name: "UserData", Actions: []policy.Action{policy.ActionReadPersonal, policy.ActionUpdatePersonal}, diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 68b765db3f8a6..345da8d812167 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -17,6 +17,7 @@ const ( ResourceGroup RBACResource = "group" ResourceGroupMember RBACResource = "group_member" ResourceIdpsyncSettings RBACResource = "idpsync_settings" + ResourceInboxNotification RBACResource = "inbox_notification" ResourceLicense RBACResource = "license" ResourceNotificationMessage RBACResource = "notification_message" ResourceNotificationPreference RBACResource = "notification_preference" @@ -74,6 +75,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceGroup: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceGroupMember: {ActionRead}, ResourceIdpsyncSettings: {ActionRead, ActionUpdate}, + ResourceInboxNotification: {ActionCreate, ActionRead, ActionUpdate}, ResourceLicense: {ActionCreate, ActionDelete, ActionRead}, ResourceNotificationMessage: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceNotificationPreference: {ActionRead, ActionUpdate}, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index d29774663bc32..5dc39cee2d088 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -193,6 +193,7 @@ Status Code **200** | `resource_type` | `group` | | `resource_type` | `group_member` | | `resource_type` | `idpsync_settings` | +| `resource_type` | `inbox_notification` | | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | @@ -356,6 +357,7 @@ Status Code **200** | `resource_type` | `group` | | `resource_type` | `group_member` | | `resource_type` | `idpsync_settings` | +| `resource_type` | `inbox_notification` | | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | @@ -519,6 +521,7 @@ Status Code **200** | `resource_type` | `group` | | `resource_type` | `group_member` | | `resource_type` | `idpsync_settings` | +| `resource_type` | `inbox_notification` | | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | @@ -651,6 +654,7 @@ Status Code **200** | `resource_type` | `group` | | `resource_type` | `group_member` | | `resource_type` | `idpsync_settings` | +| `resource_type` | `inbox_notification` | | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | @@ -915,6 +919,7 @@ Status Code **200** | `resource_type` | `group` | | `resource_type` | `group_member` | | `resource_type` | `idpsync_settings` | +| `resource_type` | `inbox_notification` | | `resource_type` | `license` | | `resource_type` | `notification_message` | | `resource_type` | `notification_preference` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index b3e4821c2e39e..ffb440675cb21 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5137,6 +5137,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `group` | | `group_member` | | `idpsync_settings` | +| `inbox_notification` | | `license` | | `notification_message` | | `notification_preference` | diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index bfd1a46861090..dc37e2b04d4fe 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -64,6 +64,11 @@ export const RBACResourceActions: Partial< read: "read IdP sync settings", update: "update IdP sync settings", }, + inbox_notification: { + create: "create inbox notifications", + read: "read inbox notifications", + update: "update inbox notifications", + }, license: { create: "create a license", delete: "delete license", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8c350d8f5bc31..0535b2b8b50de 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1895,6 +1895,7 @@ export type RBACResource = | "group" | "group_member" | "idpsync_settings" + | "inbox_notification" | "license" | "notification_message" | "notification_preference" @@ -1930,6 +1931,7 @@ export const RBACResources: RBACResource[] = [ "group", "group_member", "idpsync_settings", + "inbox_notification", "license", "notification_message", "notification_preference", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy