From 24792ad9b621847dfe30d70bf7cfba8aa69022f9 Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Fri, 13 Dec 2024 02:05:58 +0800 Subject: [PATCH] feat: add notification inbox (#3541) * feat(db): add notification inbox tables - Added `notifications` table to store notification messages. - Added `readed_notifications` table to track which notifications have been read by users. * update * update * update * update * update * add notifications dao * update * add NotificationKind * [autofix.ci] apply automated fixes * update * feat(graphQL): add notifications api Signed-off-by: Wei Zhang * feat(graphQL): add list notifications and mark read * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) --------- Signed-off-by: Wei Zhang Co-authored-by: Meng Zhang Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../0039_add-notification-inbox.down.sql | 2 + .../0039_add-notification-inbox.up.sql | 26 + ee/tabby-db/schema.sqlite | Bin 204800 -> 221184 bytes ee/tabby-db/schema/schema.sql | 19 + ee/tabby-db/schema/schema.svg | 1296 +++++++++-------- ee/tabby-db/src/lib.rs | 2 + ee/tabby-db/src/notifications.rs | 147 ++ ee/tabby-schema/graphql/schema.graphql | 10 + ee/tabby-schema/src/dao.rs | 34 +- ee/tabby-schema/src/schema/mod.rs | 18 + ee/tabby-schema/src/schema/notification.rs | 27 + ee/tabby-webserver/src/service/mod.rs | 9 + .../src/service/notification.rs | 240 +++ 13 files changed, 1212 insertions(+), 618 deletions(-) create mode 100644 ee/tabby-db/migrations/0039_add-notification-inbox.down.sql create mode 100644 ee/tabby-db/migrations/0039_add-notification-inbox.up.sql create mode 100644 ee/tabby-db/src/notifications.rs create mode 100644 ee/tabby-schema/src/schema/notification.rs create mode 100644 ee/tabby-webserver/src/service/notification.rs diff --git a/ee/tabby-db/migrations/0039_add-notification-inbox.down.sql b/ee/tabby-db/migrations/0039_add-notification-inbox.down.sql new file mode 100644 index 000000000000..34e0300a477f --- /dev/null +++ b/ee/tabby-db/migrations/0039_add-notification-inbox.down.sql @@ -0,0 +1,2 @@ +DROP TABLE notifications; +DROP TABLE read_notifications; \ No newline at end of file diff --git a/ee/tabby-db/migrations/0039_add-notification-inbox.up.sql b/ee/tabby-db/migrations/0039_add-notification-inbox.up.sql new file mode 100644 index 000000000000..3b18f122df16 --- /dev/null +++ b/ee/tabby-db/migrations/0039_add-notification-inbox.up.sql @@ -0,0 +1,26 @@ +CREATE TABLE notifications ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + + created_at TIMESTAMP NOT NULL DEFAULT(DATETIME('now')), + updated_at TIMESTAMP NOT NULL DEFAULT(DATETIME('now')), + + -- enum of admin, all_user + recipient VARCHAR(255) NOT NULL DEFAULT 'admin', + + -- content of notification, in markdown format. + content TEXT NOT NULL +); + +CREATE TABLE read_notifications ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + notification_id INTEGER NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT(DATETIME('now')), + updated_at TIMESTAMP NOT NULL DEFAULT(DATETIME('now')), + + CONSTRAINT idx_unique_user_id_notification_id UNIQUE (user_id, notification_id), + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE +) \ No newline at end of file diff --git a/ee/tabby-db/schema.sqlite b/ee/tabby-db/schema.sqlite index 868e87017b2ccdf4c788857351f7ecac50ae693e..e37f65dce475e0c4b6911249ff2a985ca9cb6d19 100644 GIT binary patch delta 2301 zcmdUvdrTZf9LH~FkKN;d-T}wGxmzCI(e@5@k4KTJEsw)CP-uCyO_ayN-N6BOi+4v$ zODa`tNSaci(_~}NV6nClQmrMCs4=bfk1DAqZ7`Zt8XpOXf7nQ(jR~YWOTfS`fBKJ? zotd5e%U+C&Aq(q z=f`(y>xe5E%lB{3zh1XQJp33;?Ul%n(ZMCeo!iq-b9X3tcDCK#E05E~hXA zT=)fjm*SM7#8ieLN+H^l%1F^EjFiS#(DhVV7Kt)9ZhZc&>IR)Rvhd9b^tn-%_OC{qTl)gCnq=zLnixX+ zQ5|~KlqKEK3`WHH9JFZ4kfJm*3qQz5`^++O+>Aqg8bVX%bg9v|*r;mx4k}E}*FtVI@faWc-P=u9rzq|ONngZKJfz-Huj^PrD`eU(RxMQm-;;al|8q%YS zR@r~zON`*vCbVpo#o3Y0!r=p`DP5NLFeZ^d&?o6KcaJdPi%^D5rb4w1?^T(F2IXkV z#=`L?v|yv9DkRvkQ6@sq*=1)N5Yc;6=ncCpiLdR9ntbV^TD+DtHbl`?P-B}(X6Ck$ zaRdNP$53|OY<3=iED6iNkDzJnQuFNDR&W>y^*p>bJJJSTcF)8ye`+R2>B&)Ma<3LY zH})#EGs@E|_8X%KTGLMKe7$Ai_e|W(;)vt?pt*2R=}?hR6jUquBAIleyLs9K<= zmG@vF_+C+?s00;wu|o09SgEy&tRPn>LsU6sa5}-612L|x$G`E`jBc9bm)Chq8@zNw zX<4w&s%b&=Dh#uS^j^WhlAqV-1qE;<;dLtLcW#~%;V0X`DqV!7C2eR+fTXaikSi(6xL zweVRxYz7`7i-T2wo#i=r5DJk1tOkr=^}*dBZ}zAUUS3oEW&nDCz;wbA;1IgSVdw0X zPI!%+Watg@=dGRzJ{B#0F(#?T6)Lh&q4_E)n&eQQRl8O1kcFzlGaQ{x*1cw{pbRVv zhC5aTjrciV%f{VAp{khF+-n*=lW-{YHGHIQYfy1n|rtP(jngr5z z6K1tjP#EY^AIBjGu2KA>rDHCP!2WPk6vj~0sceW<3uWLB6#wWzI<3zMg5K@8=Q!RE z-{*PW?>+Y}Ty`&9ZmveXZi~gDVElXK7Vl08ZcAU~OcO3q{+14U_$~fc^ZV}|J8La2U#|W%=dt2Cm#0Eqqm0|rEB?aqV#s$aU1i!Dlg{7qw z(~*+SFa`GeWV%*`ZQTdgeLXr-_J~Z$^rtiOJ<#0BIC^CR?rUX6G>?ee z{SusO#V-D@x~Ze9c?kOxM)ZJR?mZ$Oj@wBhb=T8ed~QaqNTqyHRhY6zGUR#7k9igg zaLwOk%+nNLT+??SLpl&Qq6tJ+u?Mt3+KAQ=k~ny_rDF>f+?RIViNiDqa%Jh>Cyf1KUp4=Dsv^N zP?}Jmxsyk)hGZK23qA;$+Ws+w?uB|G7B;!}aF}uY^q25LxWl-kIwHDp3T}qmjVKV2 z>G*ZnA2E?*5ft6H0n3p#qv&fCJyHZmG-f196dj*j0yWxaL`#Sq{jKm-)KrR#$h*JJ z(q`)WWhCEh!o@aIx?hn@hM_%fa$zWr*z*<~1y5LG+x*@nPEMi!SIZ=8IBJW90B zi)6L&${2Z0s=F38leH?VWj%PG&ssfo>%#3stt#$tJ9;f{(qZ8*@Q2)e&I68j9Zxx! z?UMa>Ys^+59o!{6lL*mH=D0zkG)|1sgH)4p+%QRMah2{RO#5Pr3fdnNw7D^_(i220 z4|6-UXGgh*wC)jZh{%nVDXI`#|2~`WU@-cO6bfP>yS5!;0Gu@3t1-fd}e#n#68WV8~uVr0L-6-oq3x4D4+oy=N#)1o;M!`>a(xe*+*~ZZiM? diff --git a/ee/tabby-db/schema/schema.sql b/ee/tabby-db/schema/schema.sql index c972bc2a081c..ef1e248389cc 100644 --- a/ee/tabby-db/schema/schema.sql +++ b/ee/tabby-db/schema/schema.sql @@ -223,3 +223,22 @@ FOREIGN KEY(user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE, -- access_policy is unique per source_id and user_group_id CONSTRAINT idx_unique_source_id_user_group_id UNIQUE(source_id, user_group_id) ); +CREATE TABLE notifications( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + created_at TIMESTAMP NOT NULL DEFAULT(DATETIME('now')), + updated_at TIMESTAMP NOT NULL DEFAULT(DATETIME('now')), + -- enum of admin, all_user + recipient VARCHAR(255) NOT NULL DEFAULT 'admin', + -- content of notification, in markdown format. + content TEXT NOT NULL +); +CREATE TABLE readed_notifications( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + notification_id INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT(DATETIME('now')), + updated_at TIMESTAMP NOT NULL DEFAULT(DATETIME('now')), + CONSTRAINT idx_unique_user_id_notification_id UNIQUE(user_id, notification_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(notification_id) REFERENCES notifications(id) ON DELETE CASCADE +); diff --git a/ee/tabby-db/schema/schema.svg b/ee/tabby-db/schema/schema.svg index 67189b5de0f0..a5c49d083675 100644 --- a/ee/tabby-db/schema/schema.svg +++ b/ee/tabby-db/schema/schema.svg @@ -1,731 +1,795 @@ - - - + + structs - + _sqlx_migrations - -_sqlx_migrations - -🔑 - -version - -  - -description - -  - -installed_on - -  - -success - -  - -checksum - -  - -execution_time + +_sqlx_migrations + +🔑 + +version + +  + +description + +  + +installed_on + +  + +success + +  + +checksum + +  + +execution_time email_setting - -email_setting - -🔑 - -id - -  - -smtp_username - -  - -smtp_password - -  - -smtp_server - -  - -from_address - -  - -encryption - -  - -auth_method - -  - -smtp_port + +email_setting + +🔑 + +id + +  + +smtp_username + +  + +smtp_password + +  + +smtp_server + +  + +from_address + +  + +encryption + +  + +auth_method + +  + +smtp_port integrations - -integrations - -🔑 - -id - -  - -kind - -  - -display_name - -  - -access_token - -  - -api_base - -  - -error - -  - -created_at - -  - -updated_at - -  - -synced + +integrations + +🔑 + +id + +  + +kind + +  + +display_name + +  + +access_token + +  + +api_base + +  + +error + +  + +created_at + +  + +updated_at + +  + +synced invitations - -invitations - -🔑 - -id - -  - -email - -  - -code - -  - -created_at + +invitations + +🔑 + +id + +  + +email + +  + +code + +  + +created_at job_runs - -job_runs - -🔑 - -id - -  - -job - -  - -start_ts - -  - -end_ts - -  - -exit_code - -  - -stdout - -  - -stderr - -  - -created_at - -  - -updated_at - -  - -command - -  - -started_at + +job_runs + +🔑 + +id + +  + +job + +  + +start_ts + +  + +end_ts + +  + +exit_code + +  + +stdout + +  + +stderr + +  + +created_at + +  + +updated_at + +  + +command + +  + +started_at + + + +notifications + +notifications + +🔑 + +id + +  + +created_at + +  + +updated_at + +  + +recipient + +  + +content - + oauth_credential - -oauth_credential - -🔑 - -id - -  - -provider - -  - -client_id - -  - -client_secret - -  - -created_at - -  - -updated_at + +oauth_credential + +🔑 + +id + +  + +provider + +  + +client_id + +  + +client_secret + +  + +created_at + +  + +updated_at - + password_reset - -password_reset - -🔑 - -id - -  - -user_id - -  - -code - -  - -created_at + +password_reset + +🔑 + +id + +  + +user_id + +  + +code + +  + +created_at - + users - -users - -🔑 - -id - -  - -email - -  - -is_admin - -  - -created_at - -  - -updated_at - -  - -auth_token - -  - -active - -  - -password_encrypted - -  - -avatar - -  - -name + +users + +🔑 + +id + +  + +email + +  + +is_admin + +  + +created_at + +  + +updated_at + +  + +auth_token + +  + +active + +  + +password_encrypted + +  + +avatar + +  + +name password_reset:e->users:w - - + + - + provided_repositories - -provided_repositories - -🔑 - -id - -  - -integration_id - -  - -vendor_id - -  - -name - -  - -git_url - -  - -active - -  - -created_at - -  - -updated_at + +provided_repositories + +🔑 + +id + +  + +integration_id + +  + +vendor_id + +  + +name + +  + +git_url + +  + +active + +  + +created_at + +  + +updated_at - + provided_repositories:e->integrations:w - - + + + + + +readed_notifications + +readed_notifications + +🔑 + +id + +  + +user_id + +  + +notification_id + +  + +created_at + +  + +updated_at + + + +readed_notifications:e->notifications:w + + + + + +readed_notifications:e->users:w + + - + refresh_tokens - -refresh_tokens - -🔑 - -id - -  - -user_id - -  - -token - -  - -expires_at - -  - -created_at + +refresh_tokens + +🔑 + +id + +  + +user_id + +  + +token + +  + +expires_at + +  + +created_at refresh_tokens:e->users:w - - + + - + registration_token - -registration_token - -🔑 - -id - -  - -token - -  - -created_at - -  - -updated_at + +registration_token + +🔑 + +id + +  + +token + +  + +created_at + +  + +updated_at - + repositories - -repositories - -🔑 - -id - -  - -name - -  - -git_url + +repositories + +🔑 + +id + +  + +name + +  + +git_url - + server_setting - -server_setting - -🔑 - -id - -  - -security_allowed_register_domain_list - -  - -security_disable_client_side_telemetry - -  - -network_external_url - -  - -billing_enterprise_license + +server_setting + +🔑 + +id + +  + +security_allowed_register_domain_list + +  + +security_disable_client_side_telemetry + +  + +network_external_url + +  + +billing_enterprise_license - + source_id_read_access_policies - -source_id_read_access_policies - -🔑 - -id - -  - -source_id - -  - -user_group_id - -  - -created_at - -  - -updated_at + +source_id_read_access_policies + +🔑 + +id + +  + +source_id + +  + +user_group_id + +  + +created_at + +  + +updated_at - + user_groups - -user_groups - -🔑 - -id - -  - -name - -  - -created_at - -  - -updated_at + +user_groups + +🔑 + +id + +  + +name + +  + +created_at + +  + +updated_at source_id_read_access_policies:e->user_groups:w - - + + - + thread_messages - -thread_messages - -🔑 - -id - -  - -thread_id - -  - -role - -  - -content - -  - -code_attachments - -  - -client_code_attachments - -  - -doc_attachments - -  - -created_at - -  - -updated_at + +thread_messages + +🔑 + +id + +  + +thread_id + +  + +role + +  + +content + +  + +code_attachments + +  + +client_code_attachments + +  + +doc_attachments + +  + +created_at + +  + +updated_at - + threads - -threads - -🔑 - -id - -  - -is_ephemeral - -  - -user_id - -  - -created_at - -  - -updated_at - -  - -relevant_questions + +threads + +🔑 + +id + +  + +is_ephemeral + +  + +user_id + +  + +created_at + +  + +updated_at + +  + +relevant_questions thread_messages:e->threads:w - - + + threads:e->users:w - - + + - + user_completions - -user_completions - -🔑 - -id - -  - -user_id - -  - -completion_id - -  - -language - -  - -views - -  - -selects - -  - -dismisses - -  - -created_at - -  - -updated_at + +user_completions + +🔑 + +id + +  + +user_id + +  + +completion_id + +  + +language + +  + +views + +  + +selects + +  + +dismisses + +  + +created_at + +  + +updated_at user_completions:e->users:w - - + + - + user_events - -user_events - -🔑 - -id - -  - -user_id - -  - -kind - -  - -created_at - -  - -payload + +user_events + +🔑 + +id + +  + +user_id + +  + +kind + +  + +created_at + +  + +payload user_events:e->users:w - - + + - + user_group_memberships - -user_group_memberships - -🔑 - -id - -  - -user_id - -  - -user_group_id - -  - -is_group_admin - -  - -created_at - -  - -updated_at + +user_group_memberships + +🔑 + +id + +  + +user_id + +  + +user_group_id + +  + +is_group_admin + +  + +created_at + +  + +updated_at user_group_memberships:e->user_groups:w - - + + user_group_memberships:e->users:w - - + + - + web_documents - -web_documents - -🔑 - -id - -  - -name - -  - -url - -  - -is_preset - -  - -created_at - -  - -updated_at + +web_documents + +🔑 + +id + +  + +name + +  + +url + +  + +is_preset + +  + +created_at + +  + +updated_at diff --git a/ee/tabby-db/src/lib.rs b/ee/tabby-db/src/lib.rs index 31aaaf69d88b..a2eaccfbc594 100644 --- a/ee/tabby-db/src/lib.rs +++ b/ee/tabby-db/src/lib.rs @@ -8,6 +8,7 @@ pub use email_setting::EmailSettingDAO; pub use integrations::IntegrationDAO; pub use invitations::InvitationDAO; pub use job_runs::JobRunDAO; +pub use notifications::NotificationDAO; pub use oauth_credential::OAuthCredentialDAO; pub use provided_repositories::ProvidedRepositoryDAO; pub use repositories::RepositoryDAO; @@ -33,6 +34,7 @@ mod invitations; mod job_runs; #[cfg(test)] mod migration_tests; +mod notifications; mod oauth_credential; mod password_reset; mod provided_repositories; diff --git a/ee/tabby-db/src/notifications.rs b/ee/tabby-db/src/notifications.rs new file mode 100644 index 000000000000..0abda892caac --- /dev/null +++ b/ee/tabby-db/src/notifications.rs @@ -0,0 +1,147 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use sqlx::{prelude::*, query, query_as}; + +use crate::DbConn; + +pub const NOTIFICATION_RECIPIENT_ALL_USER: &str = "all_user"; +pub const NOTIFICATION_RECIPIENT_ADMIN: &str = "admin"; + +#[derive(FromRow)] +pub struct NotificationDAO { + pub id: i64, + + pub recipient: String, + pub content: String, + pub read: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl DbConn { + pub async fn create_notification(&self, recipient: &str, content: &str) -> Result { + let res = query!( + "INSERT INTO notifications (recipient, content) VALUES (?, ?)", + recipient, + content + ) + .execute(&self.pool) + .await?; + + Ok(res.last_insert_rowid()) + } + + pub async fn mark_notification_read(&self, id: i64, user_id: i64) -> Result<()> { + query!( + "INSERT INTO read_notifications (notification_id, user_id) VALUES (?, ?)", + id, + user_id + ) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn mark_all_notifications_read_by_user(&self, user_id: i64) -> Result<()> { + let user = self + .get_user(user_id) + .await? + .context("User doesn't exist")?; + let recipient_clause = if user.is_admin { + format!( + "recipient = '{}' OR recipient = '{}'", + NOTIFICATION_RECIPIENT_ALL_USER, NOTIFICATION_RECIPIENT_ADMIN + ) + } else { + format!("recipient = '{}'", NOTIFICATION_RECIPIENT_ALL_USER) + }; + + let query = format!( + r#" +INSERT INTO read_notifications (notification_id, user_id) +SELECT + notifications.id, + ? +FROM + notifications +LEFT JOIN + read_notifications +ON + notifications.id = read_notifications.notification_id + AND read_notifications.user_id = ? +WHERE + {} + AND read_notifications.notification_id IS NULL; + "#, + recipient_clause + ); + + sqlx::query(&query) + .bind(user_id) + .bind(user_id) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn list_notifications_within_7days( + &self, + user_id: i64, + ) -> Result> { + let user = self + .get_user(user_id) + .await? + .context("User doesn't exist")?; + let recipient_clause = if user.is_admin { + format!( + "recipient = '{}' OR recipient = '{}'", + NOTIFICATION_RECIPIENT_ALL_USER, NOTIFICATION_RECIPIENT_ADMIN + ) + } else { + format!("recipient = '{}'", NOTIFICATION_RECIPIENT_ALL_USER) + }; + let date_7days_ago = Utc::now() - Duration::days(7); + let sql = format!( + r#" +SELECT + notifications.id, + notifications.created_at, + notifications.updated_at, + recipient, + content, + CASE + WHEN read_notifications.user_id IS NOT NULL THEN 1 + ELSE 0 + END AS read +FROM + notifications +LEFT JOIN + read_notifications +ON + notifications.id = read_notifications.notification_id +WHERE + ({recipient_clause}) + AND notifications.created_at > '{date_7days_ago}' + "# + ); + let notifications = query_as(&sql).fetch_all(&self.pool).await?; + Ok(notifications) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutils; + + /// Smoke test to ensure sql query is valid, actual functionality test shall happens at service level. + #[tokio::test] + async fn smoketest_list_notifications() { + let db = DbConn::new_in_memory().await.unwrap(); + let user1 = testutils::create_user(&db).await; + let notifications = db.list_notifications_within_7days(user1).await.unwrap(); + assert!(notifications.is_empty()) + } +} diff --git a/ee/tabby-schema/graphql/schema.graphql b/ee/tabby-schema/graphql/schema.graphql index 67c1b82377d1..a2296dd89afa 100644 --- a/ee/tabby-schema/graphql/schema.graphql +++ b/ee/tabby-schema/graphql/schema.graphql @@ -576,6 +576,7 @@ type Mutation { refreshToken(refreshToken: String!): RefreshTokenResponse! createInvitation(email: String!): ID! sendTestEmail(to: String!): Boolean! + markNotificationsRead(notificationId: ID): Boolean! createGitRepository(name: String!, gitUrl: String!): ID! deleteGitRepository(id: ID!): Boolean! updateGitRepository(id: ID!, name: String!, gitUrl: String!): Boolean! @@ -615,6 +616,14 @@ type NetworkSetting { externalUrl: String! } +type Notification { + id: ID! + content: String! + read: Boolean! + createdAt: DateTime! + updatedAt: DateTime! +} + type OAuthCredential { provider: OAuthProvider! clientId: String! @@ -713,6 +722,7 @@ type Query { dailyStatsInPastYear(users: [ID!]): [CompletionStats!]! dailyStats(start: DateTime!, end: DateTime!, users: [ID!], languages: [Language!]): [CompletionStats!]! userEvents(after: String, before: String, first: Int, last: Int, users: [ID!], start: DateTime!, end: DateTime!): UserEventConnection! + notifications: [Notification!]! diskUsageStats: DiskUsageStats! repositoryList: [Repository!]! contextInfo: ContextInfo! diff --git a/ee/tabby-schema/src/dao.rs b/ee/tabby-schema/src/dao.rs index 7961ca48dc24..9c1e8d70708e 100644 --- a/ee/tabby-schema/src/dao.rs +++ b/ee/tabby-schema/src/dao.rs @@ -2,7 +2,7 @@ use anyhow::bail; use hash_ids::HashIds; use lazy_static::lazy_static; use tabby_db::{ - EmailSettingDAO, IntegrationDAO, InvitationDAO, JobRunDAO, OAuthCredentialDAO, + EmailSettingDAO, IntegrationDAO, InvitationDAO, JobRunDAO, NotificationDAO, OAuthCredentialDAO, ServerSettingDAO, ThreadDAO, ThreadMessageAttachmentClientCode, ThreadMessageAttachmentCode, ThreadMessageAttachmentDoc, ThreadMessageAttachmentIssueDoc, ThreadMessageAttachmentPullDoc, ThreadMessageAttachmentWebDoc, UserEventDAO, @@ -11,6 +11,7 @@ use tabby_db::{ use crate::{ integration::{Integration, IntegrationKind, IntegrationStatus}, interface::UserValue, + notification::{Notification, NotificationRecipient}, repository::RepositoryKind, schema::{ auth::{self, OAuthCredential, OAuthProvider}, @@ -23,7 +24,7 @@ use crate::{ user_event::{EventKind, UserEvent}, CoreError, }, - thread::{self}, + thread, }; impl From for auth::Invitation { @@ -185,6 +186,18 @@ impl TryFrom for UserEvent { } } +impl From for Notification { + fn from(value: NotificationDAO) -> Self { + Self { + id: value.id.as_id(), + content: value.content, + read: value.read, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + impl From for thread::MessageAttachmentCode { fn from(value: ThreadMessageAttachmentCode) -> Self { Self { @@ -467,3 +480,20 @@ impl DbEnum for thread::Role { } } } + +impl DbEnum for NotificationRecipient { + fn as_enum_str(&self) -> &'static str { + match self { + NotificationRecipient::Admin => "admin", + NotificationRecipient::AllUser => "all_user", + } + } + + fn from_enum_str(s: &str) -> anyhow::Result { + match s { + "admin" => Ok(NotificationRecipient::Admin), + "all_user" => Ok(NotificationRecipient::AllUser), + _ => bail!("{s} is not a valid value for NotificationKind"), + } + } +} diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index 504b9ef0a02a..476dbeef6650 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -8,6 +8,7 @@ pub mod integration; pub mod interface; pub mod job; pub mod license; +pub mod notification; pub mod repository; pub mod setting; pub mod thread; @@ -40,6 +41,7 @@ use juniper::{ graphql_object, graphql_subscription, graphql_value, FieldError, GraphQLEnum, GraphQLObject, IntoFieldError, Object, RootNode, ScalarValue, Value, ID, }; +use notification::NotificationService; use repository::RepositoryGrepOutput; use tabby_common::{ api::{code::CodeSearch, event::EventLogger}, @@ -103,6 +105,7 @@ pub trait ServiceLocator: Send + Sync { fn context(&self) -> Arc; fn user_group(&self) -> Arc; fn access_policy(&self) -> Arc; + fn notification(&self) -> Arc; } pub struct Context { @@ -527,6 +530,11 @@ impl Query { .await } + async fn notifications(ctx: &Context) -> Result> { + let user = check_user(ctx).await?; + ctx.locator.notification().list(&user.id).await + } + async fn disk_usage_stats(ctx: &Context) -> Result { check_admin(ctx).await?; ctx.locator.analytic().disk_usage_stats().await @@ -988,6 +996,16 @@ impl Mutation { Ok(true) } + async fn mark_notifications_read(ctx: &Context, notification_id: Option) -> Result { + let user = check_user(ctx).await?; + + ctx.locator + .notification() + .mark_read(&user.id, notification_id) + .await?; + Ok(true) + } + async fn create_git_repository(ctx: &Context, name: String, git_url: String) -> Result { check_admin(ctx).await?; let input = repository::CreateGitRepositoryInput { name, git_url }; diff --git a/ee/tabby-schema/src/schema/notification.rs b/ee/tabby-schema/src/schema/notification.rs new file mode 100644 index 000000000000..8c34d3e24c5d --- /dev/null +++ b/ee/tabby-schema/src/schema/notification.rs @@ -0,0 +1,27 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use juniper::{GraphQLEnum, GraphQLObject, ID}; + +use crate::Result; + +#[derive(GraphQLEnum, Clone, Debug)] +pub enum NotificationRecipient { + Admin, + AllUser, +} + +#[derive(GraphQLObject)] +pub struct Notification { + pub id: ID, + pub content: String, + pub read: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[async_trait] +pub trait NotificationService: Send + Sync { + async fn list(&self, user_id: &ID) -> Result>; + + async fn mark_read(&self, user_id: &ID, id: Option) -> Result<()>; +} diff --git a/ee/tabby-webserver/src/service/mod.rs b/ee/tabby-webserver/src/service/mod.rs index 66a90352f5e4..9a8c4dd516da 100644 --- a/ee/tabby-webserver/src/service/mod.rs +++ b/ee/tabby-webserver/src/service/mod.rs @@ -9,6 +9,7 @@ pub mod event_logger; pub mod integration; pub mod job; mod license; +mod notification; mod preset_web_documents_data; pub mod repository; mod setting; @@ -51,6 +52,7 @@ use tabby_schema::{ is_demo_mode, job::JobService, license::{IsLicenseValid, LicenseService}, + notification::NotificationService, policy, repository::RepositoryService, setting::SettingService, @@ -72,6 +74,7 @@ struct ServerContext { chat: Option>, completion: Option>, auth: Arc, + notification: Arc, license: Arc, repository: Arc, integration: Arc, @@ -118,6 +121,7 @@ impl ServerContext { )); let user_group = Arc::new(user_group::create(db_conn.clone())); let access_policy = Arc::new(access_policy::create(db_conn.clone(), context.clone())); + let notification = Arc::new(notification::create(db_conn.clone())); background_job::start( db_conn.clone(), @@ -150,6 +154,7 @@ impl ServerContext { setting, user_group, access_policy, + notification, db_conn, user_rate_limiter: UserRateLimiter::default(), } @@ -290,6 +295,10 @@ impl ServiceLocator for ArcServerContext { self.0.logger.clone() } + fn notification(&self) -> Arc { + self.0.notification.clone() + } + fn job(&self) -> Arc { self.0.job.clone() } diff --git a/ee/tabby-webserver/src/service/notification.rs b/ee/tabby-webserver/src/service/notification.rs new file mode 100644 index 000000000000..0eba4fa2dad5 --- /dev/null +++ b/ee/tabby-webserver/src/service/notification.rs @@ -0,0 +1,240 @@ +use async_trait::async_trait; +use juniper::ID; +use tabby_db::DbConn; +use tabby_schema::{ + notification::{Notification, NotificationService}, + AsRowid, Result, +}; + +struct NotificationServiceImpl { + db: DbConn, +} + +pub fn create(db: DbConn) -> impl NotificationService { + NotificationServiceImpl { db } +} + +#[async_trait] +impl NotificationService for NotificationServiceImpl { + async fn list(&self, user_id: &ID) -> Result> { + let notifications = self + .db + .list_notifications_within_7days(user_id.as_rowid().unwrap()) + .await?; + Ok(notifications.into_iter().map(|n| n.into()).collect()) + } + + async fn mark_read(&self, user_id: &ID, id: Option) -> Result<()> { + if let Some(id) = id { + self.db + .mark_notification_read(id.as_rowid().unwrap(), user_id.as_rowid().unwrap()) + .await?; + } else { + self.db + .mark_all_notifications_read_by_user(user_id.as_rowid().unwrap()) + .await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use tabby_db::DbConn; + use tabby_schema::{notification::NotificationService, AsID}; + + use super::*; + + #[tokio::test] + async fn test_notification_admin_list() { + let db = DbConn::new_in_memory().await.unwrap(); + let service = create(db.clone()); + + let user_id = db + .create_user("test".into(), None, true, None) + .await + .unwrap() + .as_id(); + let notification_id = db + .create_notification("admin", "admin_list") + .await + .unwrap() + .as_id(); + + let notifications = service.list(&user_id).await.unwrap(); + assert_eq!(notifications.len(), 1); + assert_eq!(notifications[0].id, notification_id); + assert_eq!(notifications[0].content, "admin_list"); + assert!(!notifications[0].read); + } + + #[tokio::test] + async fn test_notification_admin_list_read() { + let db = DbConn::new_in_memory().await.unwrap(); + let service = create(db.clone()); + + let user_id = db + .create_user("test".into(), None, true, None) + .await + .unwrap(); + let notification_id = db + .create_notification("admin", "admin_list_read") + .await + .unwrap(); + db.mark_notification_read(notification_id, user_id) + .await + .unwrap(); + + let notifications = service.list(&user_id.as_id()).await.unwrap(); + assert_eq!(notifications.len(), 1); + assert_eq!(notifications[0].id, notification_id.as_id()); + assert_eq!(notifications[0].content, "admin_list_read"); + assert!(notifications[0].read); + } + + #[tokio::test] + async fn test_notification_admin_list_all() { + let db = DbConn::new_in_memory().await.unwrap(); + let service = create(db.clone()); + + let user_id = db + .create_user("test".into(), None, true, None) + .await + .unwrap() + .as_id(); + db.create_notification("admin", "admin_list") + .await + .unwrap() + .as_id(); + db.create_notification("all_user", "admin_list_all_user") + .await + .unwrap() + .as_id(); + + let notifications = service.list(&user_id).await.unwrap(); + assert_eq!(notifications.len(), 2); + assert_eq!(notifications[0].content, "admin_list"); + assert!(!notifications[0].read); + assert_eq!(notifications[1].content, "admin_list_all_user"); + assert!(!notifications[1].read); + } + + #[tokio::test] + async fn test_notification_admin_mark_all_read_admin() { + let db = DbConn::new_in_memory().await.unwrap(); + let service = create(db.clone()); + + let user_id = db + .create_user("test".into(), None, true, None) + .await + .unwrap() + .as_id(); + db.create_notification("admin", "admin_list").await.unwrap(); + + service.mark_read(&user_id, None).await.unwrap(); + let notifications = service.list(&user_id).await.unwrap(); + assert_eq!(notifications.len(), 1); + assert!(notifications[0].read); + } + + #[tokio::test] + async fn test_notification_admin_mark_read_twice() { + let db = DbConn::new_in_memory().await.unwrap(); + let service = create(db.clone()); + + let user_id = db + .create_user("test".into(), None, true, None) + .await + .unwrap() + .as_id(); + let notification_id = db + .create_notification("admin", "admin_list") + .await + .unwrap() + .as_id(); + + service + .mark_read(&user_id, Some(notification_id.clone())) + .await + .unwrap(); + let notifications = service.list(&user_id).await.unwrap(); + assert_eq!(notifications.len(), 1); + assert!(notifications[0].read); + + assert!(service + .mark_read(&user_id, Some(notification_id)) + .await + .is_err()) + } + + #[tokio::test] + async fn test_notification_admin_mark_all_read_twice() { + let db = DbConn::new_in_memory().await.unwrap(); + let service = create(db.clone()); + + let user_id = db + .create_user("test".into(), None, true, None) + .await + .unwrap() + .as_id(); + db.create_notification("admin", "admin_list") + .await + .unwrap() + .as_id(); + + service.mark_read(&user_id, None).await.unwrap(); + let notifications = service.list(&user_id).await.unwrap(); + assert_eq!(notifications.len(), 1); + assert!(notifications[0].read); + + // mark all read will not return error even when call twice + // but it should not create duplicated notifications + service.mark_read(&user_id, None).await.unwrap(); + assert_eq!(notifications.len(), 1); + assert!(notifications[0].read); + } + + #[tokio::test] + async fn test_notification_admin_mark_all_read_admin_and_all_user() { + let db = DbConn::new_in_memory().await.unwrap(); + let service = create(db.clone()); + + let user_id = db + .create_user("test".into(), None, true, None) + .await + .unwrap() + .as_id(); + db.create_notification("admin", "admin_list").await.unwrap(); + db.create_notification("all_user", "all_user") + .await + .unwrap(); + + service.mark_read(&user_id, None).await.unwrap(); + let notifications = service.list(&user_id).await.unwrap(); + assert_eq!(notifications.len(), 2); + assert!(notifications[0].read); + assert!(notifications[1].read); + } + + #[tokio::test] + async fn test_notification_user_mark_all_read_admin_and_all_user() { + let db = DbConn::new_in_memory().await.unwrap(); + let service = create(db.clone()); + + let user_id = db + .create_user("test".into(), None, false, None) + .await + .unwrap() + .as_id(); + db.create_notification("admin", "admin_list").await.unwrap(); + db.create_notification("all_user", "all_user") + .await + .unwrap(); + + service.mark_read(&user_id, None).await.unwrap(); + let notifications = service.list(&user_id).await.unwrap(); + assert_eq!(notifications.len(), 1); + assert!(notifications[0].read); + } +}