From 83696ed45513d388d414bb959d376a463c3b9f70 Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Thu, 12 Dec 2024 19:30:37 +0800 Subject: [PATCH] feat(graphQL): add list notifications and mark read --- .../0039_add-notification-inbox.down.sql | 2 +- .../0039_add-notification-inbox.up.sql | 2 +- ee/tabby-db/schema.sqlite | Bin 221184 -> 221184 bytes ee/tabby-db/schema/schema.svg | 1294 ++++++++--------- ee/tabby-db/src/lib.rs | 1 + ee/tabby-db/src/notifications.rs | 80 +- ee/tabby-schema/src/dao.rs | 16 +- ee/tabby-schema/src/schema/mod.rs | 66 +- ee/tabby-schema/src/schema/notification.rs | 10 + ee/tabby-webserver/src/service/mod.rs | 9 + .../src/service/notification.rs | 239 +++ 11 files changed, 1006 insertions(+), 713 deletions(-) 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 index 53c4478d75ae..34e0300a477f 100644 --- a/ee/tabby-db/migrations/0039_add-notification-inbox.down.sql +++ b/ee/tabby-db/migrations/0039_add-notification-inbox.down.sql @@ -1,2 +1,2 @@ DROP TABLE notifications; -DROP TABLE readed_notifications; \ No newline at end of file +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 index 04ca59cef9b7..3b18f122df16 100644 --- a/ee/tabby-db/migrations/0039_add-notification-inbox.up.sql +++ b/ee/tabby-db/migrations/0039_add-notification-inbox.up.sql @@ -11,7 +11,7 @@ CREATE TABLE notifications ( content TEXT NOT NULL ); -CREATE TABLE readed_notifications ( +CREATE TABLE read_notifications ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, notification_id INTEGER NOT NULL, diff --git a/ee/tabby-db/schema.sqlite b/ee/tabby-db/schema.sqlite index 349a5d2b1b1148b46232f67bb0a2348fb7af88f9..e37f65dce475e0c4b6911249ff2a985ca9cb6d19 100644 GIT binary patch delta 1624 zcmai!eQXnD9LMi@p4+u+sn>N~uf4u*g)+wqcip;7&_Q8qrZT~SlBmnpZAb0c+PQX4 zFk}NIOAN!pLvqp0Oy&!5i;6U%zWgD`A1Xl;midxkeNC2V;*>;)$q;=?LZbJOczK?? z%k#P4_xJmK@3|X0YZ^Oi$~wy+!!QkG-a0lPt*s-AYxfP7kR`-lDT0P~1`qMzp4>Hl z1T@JZpA=dj4y~`3vWH%Zsd>y*2ru(7598pIzc$s`kfgA8%_% zuU9V5pI>;s{R;Z_Jv>)deTN(h{!0b_P$nTZ_93=f0$S8+kzwsV{ItcXC9hf}1Pedm zuPm#zXhpe%Pz2(Q<&qZdCy2Elz^BToDi(`q!hjuCuQn=bm8<(s?(*n>x307wd4|)< zP%s%AP(o6up0x6xZlMU}&Q6v6vz^q|Q@}0<<=!@j zgv@8!@j-`Q+fj}nbfg+DIx4lOLX_d#b+}ce$cRW%eK3G$MYlHS3lh{gRd<6P{4Q5wy9 zWH{M@3m%`=1*4Y)RbkxZrQYrCB6?#Mzu={c_>3U#iQ@--XMDgom@AR4@-tcAOjerN zWG3VECf1x|$F6%%XN_jZ8uClJHT~qiicGP1&6P>t@W-~Z0X=tv(RFb*OqUJIr8`RP z`hsqby#&MHYvuta3Yy8o4DPW#IVS&QEBXb1kb3SubU%>u2fE=D1d{4e;0CZx zy%dK+{;xJP0o3nf;LZG89QFegxNC#@-Q%!^mq(QD&fZkd@PLxcq}SwkB;dOZIpf$> z@68TK6J(gl*>2KLGI}n|@SmE}rhwr&Ls0(;7uG+Vi}~DK^jS}{PLO3V9?!(O`;{Rj z*4vp(WfHp+J+Vw8l}yts^;_CoB0F1rJ0qL7w)k#x^+XqpS!C{)Tub+{{eqnu0sF- delta 1703 zcma)+eM}o=9LM|Im1`-KmeTi@_Hg5EtmC;WN1!tZj?RsB?1j!T*sLu*paa^a6mcq% zk-bF!aAl9?Vq79Hqo!m=*JzD@ZDz~nqA|{w;U-QZA@Oa7KhPPb_>{y%@A$*Z{gTV? z^L&5L@A=;EZuFvh^rAXxD>TYvvRWMfT>ax4-8i`^K9Pe{q-HS-xOa}6(13>YZ<=Ay z06Ch09u9ik@VI}E|H@-$Y?r^^`c%qtd$gt~{kStYR#ouEx!=F~<=y;yaeDDh=fb@M z6UL)ofAqRa-7<})3o8m>y$jaUtlI-Q59>yAg^)CMokeZ1vxu#sA?xAlJWMUhFM=d-Z5};T1jT3sBeLowdb&57P7Oga* zYLmpD0TV{-el%f{?DesUA)3yi6_cbMV}>s9=jBel0+eU{(I1Y5T=Ai(D-sR!Pp;+B z3ucBe{)H}?C2jv;#_pLbkliA2ZXRm}Q;#L}`qO`O|2@QX)nMD?@bMMBA(q zGHk`7nHiL_7K=q+V$q%fq_o+^gtcMEyC)HE+ayMl7%90T^o32b6=`Q&KYw#jr2v&# zgH(8ap1>nC$R`q!XwO;>1?{-G9X;qp`|3CG@A1Bd5xJa)mhF;#HWV|&k1wG^#ge{H zVRCB;eNZeZcNddx3o3C)T<|&YxPID-o|U4MgCQCV(5!6QT$`4U-gizV=`*|KbS`yOrl_Q@ zs&A{7bK7#w%2~yhQdW#lHt48kYW0sc*0)7rDlZ2o48r+#a6rl3MHbxM6mbx9Rg-l9Uv=h8viN3|SZ->fik95~gN2!{Ik zI3Eo2;XpK&h;&D~f{92hI=EJ@cFSIGliy4Gn;!Og>D3jPqmPoafO^DBnL0UqPHUyK gRDfBlSWV`YcJeOxe|{bVLhK+}DLfM(cTPd=-=b8{7XSbN diff --git a/ee/tabby-db/schema/schema.svg b/ee/tabby-db/schema/schema.svg index 5596f9955dd9..a5c49d083675 100644 --- a/ee/tabby-db/schema/schema.svg +++ b/ee/tabby-db/schema/schema.svg @@ -1,795 +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 + +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 + +🔑 + +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 48017908a8e8..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; diff --git a/ee/tabby-db/src/notifications.rs b/ee/tabby-db/src/notifications.rs index 520778c161a7..0abda892caac 100644 --- a/ee/tabby-db/src/notifications.rs +++ b/ee/tabby-db/src/notifications.rs @@ -4,12 +4,16 @@ 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, } @@ -27,9 +31,9 @@ impl DbConn { Ok(res.last_insert_rowid()) } - pub async fn mark_notification_readed(&self, id: i64, user_id: i64) -> Result<()> { + pub async fn mark_notification_read(&self, id: i64, user_id: i64) -> Result<()> { query!( - "INSERT INTO readed_notifications (notification_id, user_id) VALUES (?, ?)", + "INSERT INTO read_notifications (notification_id, user_id) VALUES (?, ?)", id, user_id ) @@ -39,6 +43,49 @@ impl DbConn { 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, @@ -48,16 +95,35 @@ impl DbConn { .await? .context("User doesn't exist")?; let recipient_clause = if user.is_admin { - "recipient = 'all_user' OR recipient = 'admin'" + format!( + "recipient = '{}' OR recipient = '{}'", + NOTIFICATION_RECIPIENT_ALL_USER, NOTIFICATION_RECIPIENT_ADMIN + ) } else { - "recipient = 'all_user'" + 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 - FROM notifications LEFT JOIN readed_notifications ON notifications.id = readed_notifications.notification_id - WHERE ({recipient_clause}) AND notifications.created_at > '{date_7days_ago}' AND readed_notifications.user_id IS NULL -- notification is not marked as readed +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?; diff --git a/ee/tabby-schema/src/dao.rs b/ee/tabby-schema/src/dao.rs index a89f1df9f943..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,7 +11,7 @@ use tabby_db::{ use crate::{ integration::{Integration, IntegrationKind, IntegrationStatus}, interface::UserValue, - notification::NotificationRecipient, + notification::{Notification, NotificationRecipient}, repository::RepositoryKind, schema::{ auth::{self, OAuthCredential, OAuthProvider}, @@ -186,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 { diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index 4c59508e1980..476dbeef6650 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -41,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}, @@ -104,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 { @@ -528,61 +530,9 @@ impl Query { .await } - async fn notifications( - ctx: &Context, - readed: Option, - ) -> Result> { + async fn notifications(ctx: &Context) -> Result> { let user = check_user(ctx).await?; - match readed { - Some(true) => Ok(vec![ - notification::Notification { - id: "1".to_string().into(), - content: "Hello".into(), - read: true, - created_at: Utc::now(), - updated_at: Utc::now(), - }, - notification::Notification { - id: "3".to_string().into(), - content: "Tabby".into(), - read: true, - created_at: Utc::now(), - updated_at: Utc::now(), - }, - ]), - Some(false) => Ok(vec![ - notification::Notification { - id: "2".to_string().into(), - content: "World".into(), - read: false, - created_at: Utc::now(), - updated_at: Utc::now(), - }, - notification::Notification { - id: "4".to_string().into(), - content: "Assistant".into(), - read: false, - created_at: Utc::now(), - updated_at: Utc::now(), - }, - ]), - None => Ok(vec![ - notification::Notification { - id: "5".to_string().into(), - content: "World".into(), - read: false, - created_at: Utc::now(), - updated_at: Utc::now(), - }, - notification::Notification { - id: "6".to_string().into(), - content: "Assistant".into(), - read: true, - created_at: Utc::now(), - updated_at: Utc::now(), - }, - ]), - } + ctx.locator.notification().list(&user.id).await } async fn disk_usage_stats(ctx: &Context) -> Result { @@ -1046,7 +996,13 @@ impl Mutation { Ok(true) } - async fn mark_notifications_readed(ctx: &Context, notification_ids: Vec) -> Result { + 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) } diff --git a/ee/tabby-schema/src/schema/notification.rs b/ee/tabby-schema/src/schema/notification.rs index 78cddc443ffc..8c34d3e24c5d 100644 --- a/ee/tabby-schema/src/schema/notification.rs +++ b/ee/tabby-schema/src/schema/notification.rs @@ -1,6 +1,9 @@ +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, @@ -15,3 +18,10 @@ pub struct Notification { 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..d4aa7ee53dd7 --- /dev/null +++ b/ee/tabby-webserver/src/service/notification.rs @@ -0,0 +1,239 @@ +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 super::*; + use juniper::ID; + use tabby_db::DbConn; + use tabby_schema::{notification::NotificationService, AsID}; + + #[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, ID::from(notification_id)); + assert_eq!(notifications[0].content, "admin_list"); + assert_eq!(notifications[0].read, false); + } + + #[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_eq!(notifications[0].read, true); + } + + #[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_eq!(notifications[0].read, false); + assert_eq!(notifications[1].content, "admin_list_all_user"); + assert_eq!(notifications[1].read, false); + } + + #[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_eq!(notifications[0].read, true); + } + + #[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_eq!(notifications[0].read, true); + + 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_eq!(notifications[0].read, true); + + // 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_eq!(notifications[0].read, true); + } + + #[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_eq!(notifications[0].read, true); + assert_eq!(notifications[1].read, true); + } + + #[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_eq!(notifications[0].read, true); + } +}