Skip to content

Commit

Permalink
feat: add notification inbox (#3541)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Meng Zhang <[email protected]>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Dec 12, 2024
1 parent b855821 commit 24792ad
Show file tree
Hide file tree
Showing 13 changed files with 1,212 additions and 618 deletions.
2 changes: 2 additions & 0 deletions ee/tabby-db/migrations/0039_add-notification-inbox.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP TABLE notifications;
DROP TABLE read_notifications;
26 changes: 26 additions & 0 deletions ee/tabby-db/migrations/0039_add-notification-inbox.up.sql
Original file line number Diff line number Diff line change
@@ -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
)
Binary file modified ee/tabby-db/schema.sqlite
Binary file not shown.
19 changes: 19 additions & 0 deletions ee/tabby-db/schema/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
1,296 changes: 680 additions & 616 deletions ee/tabby-db/schema/schema.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions ee/tabby-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
147 changes: 147 additions & 0 deletions ee/tabby-db/src/notifications.rs
Original file line number Diff line number Diff line change
@@ -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<Utc>,
pub updated_at: DateTime<Utc>,
}

impl DbConn {
pub async fn create_notification(&self, recipient: &str, content: &str) -> Result<i64> {
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<Vec<NotificationDAO>> {
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())
}
}
10 changes: 10 additions & 0 deletions ee/tabby-schema/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -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!
Expand Down
34 changes: 32 additions & 2 deletions ee/tabby-schema/src/dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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},
Expand All @@ -23,7 +24,7 @@ use crate::{
user_event::{EventKind, UserEvent},
CoreError,
},
thread::{self},
thread,
};

impl From<InvitationDAO> for auth::Invitation {
Expand Down Expand Up @@ -185,6 +186,18 @@ impl TryFrom<UserEventDAO> for UserEvent {
}
}

impl From<NotificationDAO> 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<ThreadMessageAttachmentCode> for thread::MessageAttachmentCode {
fn from(value: ThreadMessageAttachmentCode) -> Self {
Self {
Expand Down Expand Up @@ -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<Self> {
match s {
"admin" => Ok(NotificationRecipient::Admin),
"all_user" => Ok(NotificationRecipient::AllUser),
_ => bail!("{s} is not a valid value for NotificationKind"),
}
}
}
18 changes: 18 additions & 0 deletions ee/tabby-schema/src/schema/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -103,6 +105,7 @@ pub trait ServiceLocator: Send + Sync {
fn context(&self) -> Arc<dyn ContextService>;
fn user_group(&self) -> Arc<dyn UserGroupService>;
fn access_policy(&self) -> Arc<dyn AccessPolicyService>;
fn notification(&self) -> Arc<dyn NotificationService>;
}

pub struct Context {
Expand Down Expand Up @@ -527,6 +530,11 @@ impl Query {
.await
}

async fn notifications(ctx: &Context) -> Result<Vec<notification::Notification>> {
let user = check_user(ctx).await?;
ctx.locator.notification().list(&user.id).await
}

async fn disk_usage_stats(ctx: &Context) -> Result<DiskUsageStats> {
check_admin(ctx).await?;
ctx.locator.analytic().disk_usage_stats().await
Expand Down Expand Up @@ -988,6 +996,16 @@ impl Mutation {
Ok(true)
}

async fn mark_notifications_read(ctx: &Context, notification_id: Option<ID>) -> Result<bool> {
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<ID> {
check_admin(ctx).await?;
let input = repository::CreateGitRepositoryInput { name, git_url };
Expand Down
27 changes: 27 additions & 0 deletions ee/tabby-schema/src/schema/notification.rs
Original file line number Diff line number Diff line change
@@ -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<Utc>,
pub updated_at: DateTime<Utc>,
}

#[async_trait]
pub trait NotificationService: Send + Sync {
async fn list(&self, user_id: &ID) -> Result<Vec<Notification>>;

async fn mark_read(&self, user_id: &ID, id: Option<ID>) -> Result<()>;
}
Loading

0 comments on commit 24792ad

Please sign in to comment.