Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/store/src/lib.rs +2 −0
| @@ -35,6 +35,7 @@ mod keys; | |||
| 35 | 35 | mod labels; | |
| 36 | 36 | mod lfs; | |
| 37 | 37 | mod mirrors; | |
| 38 | + | mod notifications; | |
| 38 | 39 | mod releases; | |
| 39 | 40 | mod repos; | |
| 40 | 41 | mod sessions; | |
| @@ -54,6 +55,7 @@ pub use crate::invites::NewInvite; | |||
| 54 | 55 | pub use crate::issues::StateCounts; | |
| 55 | 56 | pub use crate::keys::NewKey; | |
| 56 | 57 | pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror}; | |
| 58 | + | pub use crate::notifications::{NewNotification, Notification}; | |
| 57 | 59 | pub use crate::releases::{NewRelease, Release, ReleaseAsset}; | |
| 58 | 60 | pub use crate::repos::{Collaborator, NewRepo}; | |
| 59 | 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); | |