OXYGENHUB

Coverage / crates/hub/src/db/notify.rs 93.0%

212 of 228 lines run, 38 of 80 functions · run #523 on main · run missed

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 {
31108×
    pub fn as_str(self) -> &'static str {
32108×
        match self {
3376×
            Reason::Participating => "participating",
3432×
            Reason::Watching => "watching",
35
        }
36108×
    }
37
}
38
39
/// The thread a notification belongs to: its page, without a fragment.
40216×
pub fn thread_of(url: &str) -> &str {
41216×
    url.split('#').next().unwrap_or(url)
42216×
}
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
55108×
pub async fn add(pool: &Pool, n: New<'_>) -> sqlx::Result<()> {
56108×
    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.
58108×
    sqlx::query("UPDATE notifications SET done = FALSE WHERE account_id = $1 AND thread = $2 AND done = TRUE")
59108×
        .bind(account_id)
60108×
        .bind(thread_of(url))
61108×
        .execute(pool)
62108×
        .await?;
63108×
    sqlx::query("INSERT INTO notifications (account_id, at_ms, kind, title, url, actor, repo_id, thread, reason) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)")
64108×
        .bind(account_id)
65108×
        .bind(now_ms())
66108×
        .bind(kind)
67108×
        .bind(title)
68108×
        .bind(url)
69108×
        .bind(actor)
70108×
        .bind(repo_id)
71108×
        .bind(thread_of(url))
72108×
        .bind(reason.as_str())
73108×
        .execute(pool)
74108×
        .await?;
75108×
    Ok(())
76108×
}
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
10936×
pub async fn threads(pool: &Pool, account_id: i64, f: &Filter, limit: i64) -> sqlx::Result<Vec<Thread>> {
110
    // Each thread is shown as its newest notification (row 1 by time) with
111
    // counts over the whole thread: window functions, so the same SQL runs on
112
    // SQLite and Postgres.
11336×
    let mut sql = String::from(
114
        "SELECT id, at_ms, kind, title, url, actor, thread, repo_id, \
115
         (SELECT r.owner || '/' || r.name FROM repositories r WHERE r.id = t.repo_id) AS repo, \
116
         count, unread, participating > 0 AS participating, done FROM (\
117
         SELECT n.id, n.at_ms, n.kind, n.title, n.url, n.actor, n.thread, n.repo_id, n.done, \
118
         ROW_NUMBER() OVER (PARTITION BY n.thread ORDER BY n.at_ms DESC, n.id DESC) AS newest, \
119
         COUNT(*) OVER (PARTITION BY n.thread) AS count, \
120
         SUM(CASE WHEN n.read THEN 0 ELSE 1 END) OVER (PARTITION BY n.thread) AS unread, \
121
         SUM(CASE WHEN n.reason = 'participating' THEN 1 ELSE 0 END) OVER (PARTITION BY n.thread) AS participating \
122
         FROM notifications n WHERE n.account_id = $1 AND n.done = $2",
123
    );
124
    // Placeholders numbered as they are added: $1 and $2 above.
12536×
    let mut next = 3;
12636×
    if f.kind.is_some() {
1271×
        sql.push_str(&format!(" AND (n.kind = ${} OR n.kind LIKE ${} || '.%')", next, next + 1));
1281×
        next += 2;
12935×
    }
13036×
    if f.repo_id.is_some() {
1312×
        sql.push_str(&format!(" AND n.repo_id = ${next}"));
1322×
        next += 1;
13334×
    }
13436×
    sql.push_str(") t WHERE newest = 1");
13536×
    if f.unread_only {
1361×
        sql.push_str(" AND unread > 0");
13735×
    }
13836×
    match f.reason.as_deref() {
1392×
        Some("participating") => sql.push_str(" AND participating > 0"),
1401×
        Some("watching") => sql.push_str(" AND participating = 0"),
14134×
        _ => {}
142
    }
14336×
    sql.push_str(&format!(" ORDER BY at_ms DESC LIMIT ${next}"));
14436×
    let mut q = sqlx::query_as::<_, Thread>(&sql).bind(account_id).bind(f.done);
14536×
    if let Some(k) = &f.kind {
1461×
        q = q.bind(k).bind(k);
14735×
    }
14836×
    if let Some(r) = f.repo_id {
1492×
        q = q.bind(r);
15034×
    }
15136×
    q.bind(limit).fetch_all(pool).await
15236×
}
153
154
/// Mark a thread read or unread, done or back in the inbox.
1553×
pub async fn set_thread(pool: &Pool, account_id: i64, thread: &str, read: Option<bool>, done: Option<bool>) -> sqlx::Result<()> {
1563×
    if let Some(r) = read {
1572×
        if r {
1582×
            sqlx::query("UPDATE notifications SET read = TRUE WHERE account_id = $1 AND thread = $2")
1592×
                .bind(account_id)
1602×
                .bind(thread)
1612×
                .execute(pool)
1622×
                .await?;
163
        } else {
164
            // Unread: the newest notification of the thread only.
1650×
            sqlx::query("UPDATE notifications SET read = FALSE WHERE id = (SELECT id FROM notifications WHERE account_id = $1 AND thread = $2 ORDER BY at_ms DESC, id DESC LIMIT 1)")
1660×
                .bind(account_id)
1670×
                .bind(thread)
1680×
                .execute(pool)
1690×
                .await?;
170
        }
1711×
    }
1723×
    if let Some(d) = done {
1731×
        let read_too = if d { ", read = TRUE" } else { "" };
1741×
        sqlx::query(&format!("UPDATE notifications SET done = $1{read_too} WHERE account_id = $2 AND thread = $3"))
1751×
            .bind(d)
1761×
            .bind(account_id)
1771×
            .bind(thread)
1781×
            .execute(pool)
1791×
            .await?;
1802×
    }
1813×
    Ok(())
1823×
}
183
184
/// The notification with this id, if it is the account's.
1852×
pub async fn get(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<Option<Notification>> {
1862×
    sqlx::query_as("SELECT * FROM notifications WHERE account_id = $1 AND id = $2").bind(account_id).bind(id).fetch_optional(pool).await
1872×
}
188
18923×
pub async fn inbox(pool: &Pool, account_id: i64, limit: i64) -> sqlx::Result<Vec<Notification>> {
19023×
    sqlx::query_as("SELECT * FROM notifications WHERE account_id = $1 ORDER BY read, at_ms DESC, id DESC LIMIT $2")
19123×
        .bind(account_id)
19223×
        .bind(limit)
19323×
        .fetch_all(pool)
19423×
        .await
19523×
}
196
1971139×
pub async fn unread(pool: &Pool, account_id: i64) -> sqlx::Result<i64> {
1981139×
    sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE account_id = $1 AND read = FALSE AND done = FALSE")
1991139×
        .bind(account_id)
2001139×
        .fetch_one(pool)
2011139×
        .await
2021139×
}
203
2045×
pub async fn mark_read(pool: &Pool, account_id: i64, id: Option<i64>) -> sqlx::Result<()> {
2055×
    match id {
2061×
        Some(id) => {
2071×
            sqlx::query("UPDATE notifications SET read = TRUE WHERE account_id = $1 AND id = $2")
2081×
                .bind(account_id)
2091×
                .bind(id)
2101×
                .execute(pool)
2111×
                .await?
212
        }
2134×
        None => sqlx::query("UPDATE notifications SET read = TRUE WHERE account_id = $1").bind(account_id).execute(pool).await?,
214
    };
2155×
    Ok(())
2165×
}
217
218
// ---------------------------------------------------------------- watching
219
220
/// The accounts that own repository `repo_id`: its owner, or the owners of
221
/// the organisation that owns it. They watch it unless they mute it
222
/// (oxygenhub#183).
223225×
pub async fn owners_of(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<i64>> {
224225×
    let owner: Option<String> =
225225×
        sqlx::query_scalar("SELECT owner FROM repositories WHERE id = $1").bind(repo_id).fetch_optional(pool).await?;
226225×
    let Some(owner) = owner else { return Ok(Vec::new()) };
227224×
    if let Some(a) = super::accounts::account_by_name(pool, &owner).await? {
228204×
        return Ok(vec![a.id]);
22920×
    }
23020×
    match super::accounts::org_by_name(pool, &owner).await? {
2314×
        Some(o) => {
2326×
            Ok(super::accounts::org_members(pool, o.id).await?.into_iter().filter(|(_, r)| r == "owner").map(|(a, _)| a.id).collect())
233
        }
23416×
        None => Ok(Vec::new()),
235
    }
236225×
}
237
238
/// Whether `account_id` watches `repo_id`: a watch it chose, or, for an
239
/// owner, unless it muted it.
24030×
pub async fn watching(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result<bool> {
24130×
    let muted: Option<bool> = sqlx::query_scalar("SELECT muted FROM watches WHERE account_id = $1 AND repo_id = $2")
24230×
        .bind(account_id)
24330×
        .bind(repo_id)
24430×
        .fetch_optional(pool)
24530×
        .await?;
24630×
    match muted {
2474×
        Some(m) => Ok(!m),
24826×
        None => Ok(owners_of(pool, repo_id).await?.contains(&account_id)),
249
    }
25030×
}
251
252
/// Watch `repo_id`, or stop: for an owner, stopping mutes it.
2535×
pub async fn set_watch(pool: &Pool, account_id: i64, repo_id: i64, on: bool) -> sqlx::Result<()> {
2545×
    sqlx::query("INSERT INTO watches (account_id, repo_id, muted) VALUES ($1, $2, $3) ON CONFLICT (account_id, repo_id) DO UPDATE SET muted = excluded.muted")
2555×
        .bind(account_id)
2565×
        .bind(repo_id)
2575×
        .bind(!on)
2585×
        .execute(pool)
2595×
        .await?;
2605×
    Ok(())
2615×
}
262
263
/// Everyone watching `repo_id`: those who chose to, and its owners who
264
/// have not muted it.
265199×
pub async fn watchers(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<i64>> {
266199×
    let rows: Vec<(i64, bool)> =
267199×
        sqlx::query_as("SELECT account_id, muted FROM watches WHERE repo_id = $1").bind(repo_id).fetch_all(pool).await?;
268199×
    let mut out: Vec<i64> = rows.iter().filter(|(_, m)| !m).map(|(a, _)| *a).collect();
269199×
    for o in owners_of(pool, repo_id).await? {
270182×
        if !rows.iter().any(|(a, _)| *a == o) && !out.contains(&o) {
271177×
            out.push(o);
272177×
        }
273
    }
274199×
    Ok(out)
275199×
}
276
277100×
pub async fn subscribe(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<()> {
278100×
    sqlx::query("INSERT INTO subscriptions (account_id, kind, target_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING")
279100×
        .bind(account_id)
280100×
        .bind(kind)
281100×
        .bind(target_id)
282100×
        .execute(pool)
283100×
        .await?;
284100×
    Ok(())
285100×
}
286
2870×
pub async fn unsubscribe(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<()> {
2880×
    sqlx::query("DELETE FROM subscriptions WHERE account_id = $1 AND kind = $2 AND target_id = $3")
2890×
        .bind(account_id)
2900×
        .bind(kind)
2910×
        .bind(target_id)
2920×
        .execute(pool)
2930×
        .await?;
2940×
    Ok(())
2950×
}
296
29717×
pub async fn subscribed(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<bool> {
29817×
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions WHERE account_id = $1 AND kind = $2 AND target_id = $3")
29917×
        .bind(account_id)
30017×
        .bind(kind)
30117×
        .bind(target_id)
30217×
        .fetch_one(pool)
30317×
        .await?;
30417×
    Ok(n > 0)
30517×
}
306
307102×
pub async fn subscribers(pool: &Pool, kind: &str, target_id: i64) -> sqlx::Result<Vec<i64>> {
308102×
    sqlx::query_scalar("SELECT account_id FROM subscriptions WHERE kind = $1 AND target_id = $2")
309102×
        .bind(kind)
310102×
        .bind(target_id)
311102×
        .fetch_all(pool)
312102×
        .await
313102×
}
314
315
// ------------------------------------------------------------------ search
316
317
#[derive(Clone, Debug, FromRow)]
318
pub struct Hit {
319
    pub kind: String,
320
    pub repo_id: i64,
321
    pub target_id: i64,
322
    pub number: i64,
323
    pub title: String,
324
    pub snippet: String,
325
}
326
327
/// Replace the indexed text of one thread (title plus body plus comments).
32876×
pub async fn index(pool: &Pool, kind: &str, repo_id: i64, target_id: i64, number: i64, title: &str, body: &str) -> sqlx::Result<()> {
32976×
    sqlx::query("DELETE FROM search WHERE kind = $1 AND target_id = $2").bind(kind).bind(target_id).execute(pool).await?;
33076×
    sqlx::query("INSERT INTO search (kind, repo_id, target_id, number, title, body) VALUES ($1, $2, $3, $4, $5, $6)")
33176×
        .bind(kind)
33276×
        .bind(repo_id)
33376×
        .bind(target_id)
33476×
        .bind(number)
33576×
        .bind(title)
33676×
        .bind(body)
33776×
        .execute(pool)
33876×
        .await?;
33976×
    Ok(())
34076×
}
341
342
/// Full-text hits, best first. The query is quoted so punctuation cannot
343
/// reach the FTS5 parser.
344
#[cfg(not(feature = "postgres"))]
3458×
pub async fn search(pool: &Pool, query: &str, repo_id: Option<i64>, limit: i64) -> sqlx::Result<Vec<Hit>> {
3468×
    let terms: Vec<String> = query.split_whitespace().map(|t| format!("\"{}\"", t.replace('"', ""))).collect();
3478×
    if terms.is_empty() {
3480×
        return Ok(vec![]);
3498×
    }
3508×
    let q = terms.join(" ");
3518×
    let sql = "SELECT kind, repo_id, target_id, number, title, snippet(search, 5, '<mark>', '</mark>', '…', 12) AS snippet FROM search WHERE search MATCH $1 AND ($2 IS NULL OR repo_id = $3) ORDER BY rank LIMIT $4";
3528×
    sqlx::query_as(sql).bind(q).bind(repo_id).bind(repo_id).bind(limit).fetch_all(pool).await
3538×
}
354
355
/// Full-text hits, best first, as SQLite's: every word must appear
356
/// (stemmed, as FTS5's porter tokenizer does), titles weigh more, and the
357
/// snippet is from the body with the words marked.
358
#[cfg(feature = "postgres")]
359
pub async fn search(pool: &Pool, query: &str, repo_id: Option<i64>, limit: i64) -> sqlx::Result<Vec<Hit>> {
360
    if query.split_whitespace().next().is_none() {
361
        return Ok(vec![]);
362
    }
363
    let sql = "SELECT kind, repo_id, target_id, number, title, \
364
               ts_headline('english', body, q, 'StartSel=\"<mark>\", StopSel=\"</mark>\", MaxWords=12, MinWords=4') AS snippet \
365
               FROM search, plainto_tsquery('english', $1) q WHERE doc @@ q AND ($2 IS NULL OR repo_id = $3) \
366
               ORDER BY ts_rank(doc, q) DESC LIMIT $4";
367
    sqlx::query_as(sql).bind(query).bind(repo_id).bind(repo_id).bind(limit).fetch_all(pool).await
368
}