Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/store/src/lib.rs +2 −0
| @@ -35,6 +35,7 @@ mod keys; | |||
| 35 | mod labels; | 35 | mod labels; |
| 36 | mod lfs; | 36 | mod lfs; |
| 37 | mod mirrors; | 37 | mod mirrors; |
| 38 | mod notifications; | ||
| 38 | mod releases; | 39 | mod releases; |
| 39 | mod repos; | 40 | mod repos; |
| 40 | mod sessions; | 41 | mod sessions; |
| @@ -54,6 +55,7 @@ pub use crate::invites::NewInvite; | |||
| 54 | pub use crate::issues::StateCounts; | 55 | pub use crate::issues::StateCounts; |
| 55 | pub use crate::keys::NewKey; | 56 | pub use crate::keys::NewKey; |
| 56 | pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror}; | 57 | pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror}; |
| 58 | pub use crate::notifications::{NewNotification, Notification}; | ||
| 57 | pub use crate::releases::{NewRelease, Release, ReleaseAsset}; | 59 | pub use crate::releases::{NewRelease, Release, ReleaseAsset}; |
| 58 | pub use crate::repos::{Collaborator, NewRepo}; | 60 | pub use crate::repos::{Collaborator, NewRepo}; |
| 59 | pub use crate::sessions::NewSession; | 61 | pub use crate::sessions::NewSession; |
crates/store/src/notifications.rs +165 −0
| @@ -0,0 +1,165 @@ | |||
| 1 | // This Source Code Form is subject to the terms of the Mozilla Public | ||
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | ||
| 4 | |||
| 5 | //! Notifications: an event (an @mention, an assignment) directed at a user. The | ||
| 6 | //! row stores foreign keys only; the web layer resolves actor/repo/issue at | ||
| 7 | //! display time. | ||
| 8 | |||
| 9 | use sqlx::any::AnyRow; | ||
| 10 | use sqlx::{AssertSqlSafe, Row}; | ||
| 11 | |||
| 12 | use crate::{Store, StoreError, new_id, now_ms}; | ||
| 13 | |||
| 14 | /// A pending notification. | ||
| 15 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 16 | pub struct Notification { | ||
| 17 | /// ULID primary key. | ||
| 18 | pub id: String, | ||
| 19 | /// The recipient. | ||
| 20 | pub user_id: String, | ||
| 21 | /// Who triggered it, if still present. | ||
| 22 | pub actor_id: Option<String>, | ||
| 23 | /// The kind (`mention`, `assign`). | ||
| 24 | pub kind: String, | ||
| 25 | /// The repo it concerns, if any. | ||
| 26 | pub repo_id: Option<String>, | ||
| 27 | /// The issue/PR it concerns, if any. | ||
| 28 | pub issue_id: Option<String>, | ||
| 29 | /// When it was read, if it has been. | ||
| 30 | pub read_at: Option<i64>, | ||
| 31 | /// Creation time. | ||
| 32 | pub created_at: i64, | ||
| 33 | } | ||
| 34 | |||
| 35 | /// Fields to create a notification. | ||
| 36 | #[derive(Debug, Clone)] | ||
| 37 | pub struct NewNotification { | ||
| 38 | /// The recipient. | ||
| 39 | pub user_id: String, | ||
| 40 | /// Who triggered it. | ||
| 41 | pub actor_id: String, | ||
| 42 | /// The kind. | ||
| 43 | pub kind: String, | ||
| 44 | /// The repo it concerns. | ||
| 45 | pub repo_id: Option<String>, | ||
| 46 | /// The issue/PR it concerns. | ||
| 47 | pub issue_id: Option<String>, | ||
| 48 | } | ||
| 49 | |||
| 50 | const NOTIF_COLUMNS: &str = "id, user_id, actor_id, kind, repo_id, issue_id, read_at, created_at"; | ||
| 51 | |||
| 52 | impl Store { | ||
| 53 | fn map_notification(row: &AnyRow) -> Result<Notification, sqlx::Error> { | ||
| 54 | Ok(Notification { | ||
| 55 | id: row.try_get("id")?, | ||
| 56 | user_id: row.try_get("user_id")?, | ||
| 57 | actor_id: row.try_get("actor_id")?, | ||
| 58 | kind: row.try_get("kind")?, | ||
| 59 | repo_id: row.try_get("repo_id")?, | ||
| 60 | issue_id: row.try_get("issue_id")?, | ||
| 61 | read_at: row.try_get("read_at")?, | ||
| 62 | created_at: row.try_get("created_at")?, | ||
| 63 | }) | ||
| 64 | } | ||
| 65 | |||
| 66 | /// Create a notification. A self-directed notification (actor == recipient) | ||
| 67 | /// is skipped — you are not notified of your own actions. | ||
| 68 | /// | ||
| 69 | /// # Errors | ||
| 70 | /// | ||
| 71 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 72 | pub async fn create_notification(&self, new: NewNotification) -> Result<(), StoreError> { | ||
| 73 | if new.user_id == new.actor_id { | ||
| 74 | return Ok(()); | ||
| 75 | } | ||
| 76 | sqlx::query( | ||
| 77 | "INSERT INTO notifications \ | ||
| 78 | (id, user_id, actor_id, kind, repo_id, issue_id, read_at, created_at) \ | ||
| 79 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", | ||
| 80 | ) | ||
| 81 | .bind(new_id()) | ||
| 82 | .bind(&new.user_id) | ||
| 83 | .bind(&new.actor_id) | ||
| 84 | .bind(&new.kind) | ||
| 85 | .bind(&new.repo_id) | ||
| 86 | .bind(&new.issue_id) | ||
| 87 | .bind(Option::<i64>::None) | ||
| 88 | .bind(now_ms()) | ||
| 89 | .execute(&self.pool) | ||
| 90 | .await?; | ||
| 91 | Ok(()) | ||
| 92 | } | ||
| 93 | |||
| 94 | /// A user's notifications, newest first, capped at `limit`. | ||
| 95 | /// | ||
| 96 | /// # Errors | ||
| 97 | /// | ||
| 98 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 99 | pub async fn list_notifications( | ||
| 100 | &self, | ||
| 101 | user_id: &str, | ||
| 102 | limit: i64, | ||
| 103 | ) -> Result<Vec<Notification>, StoreError> { | ||
| 104 | let sql = format!( | ||
| 105 | "SELECT {NOTIF_COLUMNS} FROM notifications WHERE user_id = $1 \ | ||
| 106 | ORDER BY created_at DESC LIMIT $2" | ||
| 107 | ); | ||
| 108 | let rows = sqlx::query(AssertSqlSafe(sql)) | ||
| 109 | .bind(user_id) | ||
| 110 | .bind(limit) | ||
| 111 | .fetch_all(&self.pool) | ||
| 112 | .await?; | ||
| 113 | rows.iter() | ||
| 114 | .map(Self::map_notification) | ||
| 115 | .collect::<Result<_, _>>() | ||
| 116 | .map_err(Into::into) | ||
| 117 | } | ||
| 118 | |||
| 119 | /// The number of unread notifications for a user. | ||
| 120 | /// | ||
| 121 | /// # Errors | ||
| 122 | /// | ||
| 123 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 124 | pub async fn count_unread_notifications(&self, user_id: &str) -> Result<i64, StoreError> { | ||
| 125 | let n: i64 = sqlx::query( | ||
| 126 | "SELECT COUNT(*) AS n FROM notifications WHERE user_id = $1 AND read_at IS NULL", | ||
| 127 | ) | ||
| 128 | .bind(user_id) | ||
| 129 | .fetch_one(&self.pool) | ||
| 130 | .await? | ||
| 131 | .try_get("n")?; | ||
| 132 | Ok(n) | ||
| 133 | } | ||
| 134 | |||
| 135 | /// Mark one notification read (scoped to its owner). | ||
| 136 | /// | ||
| 137 | /// # Errors | ||
| 138 | /// | ||
| 139 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 140 | pub async fn mark_notification_read(&self, id: &str, user_id: &str) -> Result<(), StoreError> { | ||
| 141 | sqlx::query( | ||
| 142 | "UPDATE notifications SET read_at = $1 WHERE id = $2 AND user_id = $3 AND read_at IS NULL", | ||
| 143 | ) | ||
| 144 | .bind(now_ms()) | ||
| 145 | .bind(id) | ||
| 146 | .bind(user_id) | ||
| 147 | .execute(&self.pool) | ||
| 148 | .await?; | ||
| 149 | Ok(()) | ||
| 150 | } | ||
| 151 | |||
| 152 | /// Mark all of a user's notifications read. | ||
| 153 | /// | ||
| 154 | /// # Errors | ||
| 155 | /// | ||
| 156 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 157 | pub async fn mark_all_notifications_read(&self, user_id: &str) -> Result<(), StoreError> { | ||
| 158 | sqlx::query("UPDATE notifications SET read_at = $1 WHERE user_id = $2 AND read_at IS NULL") | ||
| 159 | .bind(now_ms()) | ||
| 160 | .bind(user_id) | ||
| 161 | .execute(&self.pool) | ||
| 162 | .await?; | ||
| 163 | Ok(()) | ||
| 164 | } | ||
| 165 | } | ||
migrations/postgres/0020_notifications.sql +13 −0
| @@ -0,0 +1,13 @@ | |||
| 1 | -- Notifications: an actor did something (mention, assignment) that a user should | ||
| 2 | -- see. Rendered by resolving the linked actor/repo/issue at display time. | ||
| 3 | CREATE TABLE notifications ( | ||
| 4 | id TEXT PRIMARY KEY, | ||
| 5 | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 6 | actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, | ||
| 7 | kind TEXT NOT NULL, | ||
| 8 | repo_id TEXT REFERENCES repos(id) ON DELETE CASCADE, | ||
| 9 | issue_id TEXT REFERENCES issues(id) ON DELETE CASCADE, | ||
| 10 | read_at BIGINT, | ||
| 11 | created_at BIGINT NOT NULL | ||
| 12 | ); | ||
| 13 | CREATE INDEX idx_notifications_user ON notifications (user_id, read_at); | ||
migrations/sqlite/0020_notifications.sql +13 −0
| @@ -0,0 +1,13 @@ | |||
| 1 | -- Notifications: an actor did something (mention, assignment) that a user should | ||
| 2 | -- see. Rendered by resolving the linked actor/repo/issue at display time. | ||
| 3 | CREATE TABLE notifications ( | ||
| 4 | id TEXT PRIMARY KEY, | ||
| 5 | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 6 | actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, | ||
| 7 | kind TEXT NOT NULL, | ||
| 8 | repo_id TEXT REFERENCES repos(id) ON DELETE CASCADE, | ||
| 9 | issue_id TEXT REFERENCES issues(id) ON DELETE CASCADE, | ||
| 10 | read_at BIGINT, | ||
| 11 | created_at BIGINT NOT NULL | ||
| 12 | ); | ||
| 13 | CREATE INDEX idx_notifications_user ON notifications (user_id, read_at); | ||