OXYGENHUB
# 62cdf34daec1 oxygenhub / crates / hub / src / db / notify.rs
11.8 KiB · Rust · last changed Notifications that reach you, and 2FA without the password twice 21 days ago source blame history raw
1
//! Notifications, watching, subscriptions and the search index (hub spec
2
//! §10, §11).
3
4
use super::{now_ms, Pool};
5
use sqlx::FromRow;
6
7
#[derive(Clone, Debug, FromRow)]
8
pub struct Notification {
9
    pub id: i64,
10
    pub account_id: i64,
11
    pub at_ms: i64,
12
    pub kind: String,
13
    pub title: String,
14
    pub url: String,
15
    pub actor: String,
16
    pub read: bool,
17
    pub repo_id: Option<i64>,
18
    pub thread: String,
19
    pub reason: String,
20
    pub done: bool,
21
}
22
23
/// Why someone is told: they take part in the thread, or only watch the repository.
24
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25
pub enum Reason {
26
    Participating,
27
    Watching,
28
}
29
30
impl Reason {
31
    pub fn as_str(self) -> &'static str {
32
        match self {
33
            Reason::Participating => "participating",
34
            Reason::Watching => "watching",
35
        }
36
    }
37
}
38
39
/// The thread a notification belongs to: its page, without a fragment.
40
pub fn thread_of(url: &str) -> &str {
41
    url.split('#').next().unwrap_or(url)
42
}
43
44
/// A notification to record.
45
pub struct New<'a> {
46
    pub account_id: i64,
47
    pub repo_id: Option<i64>,
48
    pub reason: Reason,
49
    pub kind: &'a str,
50
    pub title: &'a str,
51
    pub url: &'a str,
52
    pub actor: &'a str,
53
}
54
55
pub async fn add(pool: &Pool, n: New<'_>) -> sqlx::Result<()> {
56
    let New { account_id, repo_id, reason, kind, title, url, actor } = n;
57
    // A new event brings a thread that was set aside back to the inbox.
58
    sqlx::query("UPDATE notifications SET done = 0 WHERE account_id = ? AND thread = ? AND done = 1")
59
        .bind(account_id)
60
        .bind(thread_of(url))
61
        .execute(pool)
62
        .await?;
63
    sqlx::query("INSERT INTO notifications (account_id, at_ms, kind, title, url, actor, repo_id, thread, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
64
        .bind(account_id)
65
        .bind(now_ms())
66
        .bind(kind)
67
        .bind(title)
68
        .bind(url)
69
        .bind(actor)
70
        .bind(repo_id)
71
        .bind(thread_of(url))
72
        .bind(reason.as_str())
73
        .execute(pool)
74
        .await?;
75
    Ok(())
76
}
77
78
/// One inbox row: a thread, shown by its newest notification.
79
#[derive(Clone, Debug, FromRow)]
80
pub struct Thread {
81
    /// The newest notification's id.
82
    pub id: i64,
83
    pub at_ms: i64,
84
    pub kind: String,
85
    pub title: String,
86
    pub url: String,
87
    pub actor: String,
88
    pub thread: String,
89
    pub repo_id: Option<i64>,
90
    pub repo: Option<String>,
91
    pub count: i64,
92
    pub unread: i64,
93
    pub participating: bool,
94
    pub done: bool,
95
}
96
97
/// What the inbox shows.
98
#[derive(Clone, Debug, Default)]
99
pub struct Filter {
100
    pub done: bool,
101
    pub unread_only: bool,
102
    /// `participating` or `watching`.
103
    pub reason: Option<String>,
104
    /// A kind or its family (`issue` matches `issue.closed`).
105
    pub kind: Option<String>,
106
    pub repo_id: Option<i64>,
107
}
108
109
pub async fn threads(pool: &Pool, account_id: i64, f: &Filter, limit: i64) -> sqlx::Result<Vec<Thread>> {
110
    // SQLite takes bare columns from the row holding MAX(at_ms): the newest.
111
    let mut sql = String::from(
112
        "SELECT n.id, MAX(n.at_ms) AS at_ms, n.kind, n.title, n.url, n.actor, n.thread, n.repo_id, \
113
         (SELECT r.owner || '/' || r.name FROM repositories r WHERE r.id = n.repo_id) AS repo, \
114
         COUNT(*) AS count, SUM(n.read = 0) AS unread, SUM(n.reason = 'participating') > 0 AS participating, n.done \
115
         FROM notifications n WHERE n.account_id = ? AND n.done = ?",
116
    );
117
    if f.kind.is_some() {
118
        sql.push_str(" AND (n.kind = ? OR n.kind LIKE ? || '.%')");
119
    }
120
    if f.repo_id.is_some() {
121
        sql.push_str(" AND n.repo_id = ?");
122
    }
123
    sql.push_str(" GROUP BY n.thread HAVING 1");
124
    if f.unread_only {
125
        sql.push_str(" AND SUM(n.read = 0) > 0");
126
    }
127
    match f.reason.as_deref() {
128
        Some("participating") => sql.push_str(" AND SUM(n.reason = 'participating') > 0"),
129
        Some("watching") => sql.push_str(" AND SUM(n.reason = 'participating') = 0"),
130
        _ => {}
131
    }
132
    sql.push_str(" ORDER BY at_ms DESC LIMIT ?");
133
    let mut q = sqlx::query_as::<_, Thread>(&sql).bind(account_id).bind(f.done);
134
    if let Some(k) = &f.kind {
135
        q = q.bind(k).bind(k);
136
    }
137
    if let Some(r) = f.repo_id {
138
        q = q.bind(r);
139
    }
140
    q.bind(limit).fetch_all(pool).await
141
}
142
143
/// Mark a thread read or unread, done or back in the inbox.
144
pub async fn set_thread(pool: &Pool, account_id: i64, thread: &str, read: Option<bool>, done: Option<bool>) -> sqlx::Result<()> {
145
    if let Some(r) = read {
146
        if r {
147
            sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = ? AND thread = ?")
148
                .bind(account_id)
149
                .bind(thread)
150
                .execute(pool)
151
                .await?;
152
        } else {
153
            // Unread: the newest notification of the thread only.
154
            sqlx::query("UPDATE notifications SET read = 0 WHERE id = (SELECT id FROM notifications WHERE account_id = ? AND thread = ? ORDER BY at_ms DESC, id DESC LIMIT 1)")
155
                .bind(account_id)
156
                .bind(thread)
157
                .execute(pool)
158
                .await?;
159
        }
160
    }
161
    if let Some(d) = done {
162
        let read_too = if d { ", read = 1" } else { "" };
163
        sqlx::query(&format!("UPDATE notifications SET done = ?{read_too} WHERE account_id = ? AND thread = ?"))
164
            .bind(d)
165
            .bind(account_id)
166
            .bind(thread)
167
            .execute(pool)
168
            .await?;
169
    }
170
    Ok(())
171
}
172
173
/// The notification with this id, if it is the account's.
174
pub async fn get(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<Option<Notification>> {
175
    sqlx::query_as("SELECT * FROM notifications WHERE account_id = ? AND id = ?").bind(account_id).bind(id).fetch_optional(pool).await
176
}
177
178
pub async fn inbox(pool: &Pool, account_id: i64, limit: i64) -> sqlx::Result<Vec<Notification>> {
179
    sqlx::query_as("SELECT * FROM notifications WHERE account_id = ? ORDER BY read, at_ms DESC, id DESC LIMIT ?")
180
        .bind(account_id)
181
        .bind(limit)
182
        .fetch_all(pool)
183
        .await
184
}
185
186
pub async fn unread(pool: &Pool, account_id: i64) -> sqlx::Result<i64> {
187
    sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE account_id = ? AND read = 0 AND done = 0")
188
        .bind(account_id)
189
        .fetch_one(pool)
190
        .await
191
}
192
193
pub async fn mark_read(pool: &Pool, account_id: i64, id: Option<i64>) -> sqlx::Result<()> {
194
    match id {
195
        Some(id) => {
196
            sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = ? AND id = ?").bind(account_id).bind(id).execute(pool).await?
197
        }
198
        None => sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = ?").bind(account_id).execute(pool).await?,
199
    };
200
    Ok(())
201
}
202
203
// ---------------------------------------------------------------- watching
204
205
/// The accounts that own repository `repo_id`: its owner, or the owners of
206
/// the organisation that owns it. They watch it unless they mute it
207
/// (oxygenhub#183).
208
pub async fn owners_of(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<i64>> {
209
    let owner: Option<String> =
210
        sqlx::query_scalar("SELECT owner FROM repositories WHERE id = ?").bind(repo_id).fetch_optional(pool).await?;
211
    let Some(owner) = owner else { return Ok(Vec::new()) };
212
    if let Some(a) = super::accounts::account_by_name(pool, &owner).await? {
213
        return Ok(vec![a.id]);
214
    }
215
    match super::accounts::org_by_name(pool, &owner).await? {
216
        Some(o) => {
217
            Ok(super::accounts::org_members(pool, o.id).await?.into_iter().filter(|(_, r)| r == "owner").map(|(a, _)| a.id).collect())
218
        }
219
        None => Ok(Vec::new()),
220
    }
221
}
222
223
/// Whether `account_id` watches `repo_id`: a watch it chose, or, for an
224
/// owner, unless it muted it.
225
pub async fn watching(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result<bool> {
226
    let muted: Option<bool> = sqlx::query_scalar("SELECT muted FROM watches WHERE account_id = ? AND repo_id = ?")
227
        .bind(account_id)
228
        .bind(repo_id)
229
        .fetch_optional(pool)
230
        .await?;
231
    match muted {
232
        Some(m) => Ok(!m),
233
        None => Ok(owners_of(pool, repo_id).await?.contains(&account_id)),
234
    }
235
}
236
237
/// Watch `repo_id`, or stop: for an owner, stopping mutes it.
238
pub async fn set_watch(pool: &Pool, account_id: i64, repo_id: i64, on: bool) -> sqlx::Result<()> {
239
    sqlx::query("INSERT INTO watches (account_id, repo_id, muted) VALUES (?, ?, ?) ON CONFLICT (account_id, repo_id) DO UPDATE SET muted = excluded.muted")
240
        .bind(account_id)
241
        .bind(repo_id)
242
        .bind(!on)
243
        .execute(pool)
244
        .await?;
245
    Ok(())
246
}
247
248
/// Everyone watching `repo_id`: those who chose to, and its owners who
249
/// have not muted it.
250
pub async fn watchers(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<i64>> {
251
    let rows: Vec<(i64, bool)> =
252
        sqlx::query_as("SELECT account_id, muted FROM watches WHERE repo_id = ?").bind(repo_id).fetch_all(pool).await?;
253
    let mut out: Vec<i64> = rows.iter().filter(|(_, m)| !m).map(|(a, _)| *a).collect();
254
    for o in owners_of(pool, repo_id).await? {
255
        if !rows.iter().any(|(a, _)| *a == o) && !out.contains(&o) {
256
            out.push(o);
257
        }
258
    }
259
    Ok(out)
260
}
261
262
pub async fn subscribe(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<()> {
263
    sqlx::query("INSERT OR IGNORE INTO subscriptions (account_id, kind, target_id) VALUES (?, ?, ?)")
264
        .bind(account_id)
265
        .bind(kind)
266
        .bind(target_id)
267
        .execute(pool)
268
        .await?;
269
    Ok(())
270
}
271
272
pub async fn unsubscribe(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<()> {
273
    sqlx::query("DELETE FROM subscriptions WHERE account_id = ? AND kind = ? AND target_id = ?")
274
        .bind(account_id)
275
        .bind(kind)
276
        .bind(target_id)
277
        .execute(pool)
278
        .await?;
279
    Ok(())
280
}
281
282
pub async fn subscribed(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<bool> {
283
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions WHERE account_id = ? AND kind = ? AND target_id = ?")
284
        .bind(account_id)
285
        .bind(kind)
286
        .bind(target_id)
287
        .fetch_one(pool)
288
        .await?;
289
    Ok(n > 0)
290
}
291
292
pub async fn subscribers(pool: &Pool, kind: &str, target_id: i64) -> sqlx::Result<Vec<i64>> {
293
    sqlx::query_scalar("SELECT account_id FROM subscriptions WHERE kind = ? AND target_id = ?")
294
        .bind(kind)
295
        .bind(target_id)
296
        .fetch_all(pool)
297
        .await
298
}
299
300
// ------------------------------------------------------------------ search
301
302
#[derive(Clone, Debug, FromRow)]
303
pub struct Hit {
304
    pub kind: String,
305
    pub repo_id: i64,
306
    pub target_id: i64,
307
    pub number: i64,
308
    pub title: String,
309
    pub snippet: String,
310
}
311
312
/// Replace the indexed text of one thread (title plus body plus comments).
313
pub async fn index(pool: &Pool, kind: &str, repo_id: i64, target_id: i64, number: i64, title: &str, body: &str) -> sqlx::Result<()> {
314
    sqlx::query("DELETE FROM search WHERE kind = ? AND target_id = ?").bind(kind).bind(target_id).execute(pool).await?;
315
    sqlx::query("INSERT INTO search (kind, repo_id, target_id, number, title, body) VALUES (?, ?, ?, ?, ?, ?)")
316
        .bind(kind)
317
        .bind(repo_id)
318
        .bind(target_id)
319
        .bind(number)
320
        .bind(title)
321
        .bind(body)
322
        .execute(pool)
323
        .await?;
324
    Ok(())
325
}
326
327
/// Full-text hits, best first. The query is quoted so punctuation cannot
328
/// reach the FTS5 parser.
329
pub async fn search(pool: &Pool, query: &str, repo_id: Option<i64>, limit: i64) -> sqlx::Result<Vec<Hit>> {
330
    let terms: Vec<String> = query.split_whitespace().map(|t| format!("\"{}\"", t.replace('"', ""))).collect();
331
    if terms.is_empty() {
332
        return Ok(vec![]);
333
    }
334
    let q = terms.join(" ");
335
    let sql = "SELECT kind, repo_id, target_id, number, title, snippet(search, 5, '<mark>', '</mark>', '…', 12) AS snippet FROM search WHERE search MATCH ? AND (? IS NULL OR repo_id = ?) ORDER BY rank LIMIT ?";
336
    sqlx::query_as(sql).bind(q).bind(repo_id).bind(repo_id).bind(limit).fetch_all(pool).await
337
}