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 868e87017b2c..e37f65dce475 100644 Binary files a/ee/tabby-db/schema.sqlite and b/ee/tabby-db/schema.sqlite differ 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); + } +}