Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add notification inbox #3541

Merged
merged 16 commits into from
Dec 12, 2024
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>,
zwpaper marked this conversation as resolved.
Show resolved Hide resolved
}

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 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 crate::{
integration::{Integration, IntegrationKind, IntegrationStatus},
interface::UserValue,
notification::{Notification, NotificationRecipient},
repository::RepositoryKind,
schema::{
auth::{self, OAuthCredential, OAuthProvider},
Expand All @@ -23,7 +24,7 @@
user_event::{EventKind, UserEvent},
CoreError,
},
thread::{self},
thread,
};

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

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 NotificationRecipient {
fn as_enum_str(&self) -> &'static str {
match self {
NotificationRecipient::Admin => "admin",
NotificationRecipient::AllUser => "all_user",

Check warning on line 488 in ee/tabby-schema/src/dao.rs

View check run for this annotation

Codecov / codecov/patch

ee/tabby-schema/src/dao.rs#L485-L488

Added lines #L485 - L488 were not covered by tests
}
}

Check warning on line 490 in ee/tabby-schema/src/dao.rs

View check run for this annotation

Codecov / codecov/patch

ee/tabby-schema/src/dao.rs#L490

Added line #L490 was not covered by tests

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"),

Check warning on line 496 in ee/tabby-schema/src/dao.rs

View check run for this annotation

Codecov / codecov/patch

ee/tabby-schema/src/dao.rs#L492-L496

Added lines #L492 - L496 were not covered by tests
}
}

Check warning on line 498 in ee/tabby-schema/src/dao.rs

View check run for this annotation

Codecov / codecov/patch

ee/tabby-schema/src/dao.rs#L498

Added line #L498 was not covered by tests
}
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 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 @@
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 @@
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 @@
.await
}

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

Check warning on line 536 in ee/tabby-schema/src/schema/mod.rs

View check run for this annotation

Codecov / codecov/patch

ee/tabby-schema/src/schema/mod.rs#L533-L536

Added lines #L533 - L536 were not covered by tests

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 @@
Ok(true)
}

async fn mark_notifications_read(ctx: &Context, notification_id: Option<ID>) -> Result<bool> {
let user = check_user(ctx).await?;

Check warning on line 1000 in ee/tabby-schema/src/schema/mod.rs

View check run for this annotation

Codecov / codecov/patch

ee/tabby-schema/src/schema/mod.rs#L999-L1000

Added lines #L999 - L1000 were not covered by tests

ctx.locator
.notification()
.mark_read(&user.id, notification_id)
.await?;
Ok(true)
}

Check warning on line 1007 in ee/tabby-schema/src/schema/mod.rs

View check run for this annotation

Codecov / codecov/patch

ee/tabby-schema/src/schema/mod.rs#L1002-L1007

Added lines #L1002 - L1007 were not covered by tests

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)]

Check warning on line 7 in ee/tabby-schema/src/schema/notification.rs

View check run for this annotation

Codecov / codecov/patch

ee/tabby-schema/src/schema/notification.rs#L7

Added line #L7 was not covered by tests
pub enum NotificationRecipient {
Admin,
AllUser,
}

#[derive(GraphQLObject)]

Check warning on line 13 in ee/tabby-schema/src/schema/notification.rs

View check run for this annotation

Codecov / codecov/patch

ee/tabby-schema/src/schema/notification.rs#L13

Added line #L13 was not covered by tests
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
Loading