OXYGENHUB
◆ v0.9.21 oxygenhub / crates / hub / src / db / notify.rs
13.5 KiB · Rust · last changed Postgres, step 3: the hub builds and passes its tests on PostgreSQL 19 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 = FALSE WHERE account_id = $1 AND thread = $2 AND done = TRUE")
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9)")
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
    // 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.
113
    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.
125
    let mut next = 3;
126
    if f.kind.is_some() {
127
        sql.push_str(&format!(" AND (n.kind = ${} OR n.kind LIKE ${} || '.%')", next, next + 1));
128
        next += 2;
129
    }
130
    if f.repo_id.is_some() {
131
        sql.push_str(&format!(" AND n.repo_id = ${next}"));
132
        next += 1;
133
    }
134
    sql.push_str(") t WHERE newest = 1");
135
    if f.unread_only {
136
        sql.push_str(" AND unread > 0");
137
    }
138
    match f.reason.as_deref() {
139
        Some("participating") => sql.push_str(" AND participating > 0"),
140
        Some("watching") => sql.push_str(" AND participating = 0"),
141
        _ => {}
142
    }
143
    sql.push_str(&format!(" ORDER BY at_ms DESC LIMIT ${next}"));
144
    let mut q = sqlx::query_as::<_, Thread>(&sql).bind(account_id).bind(f.done);
145
    if let Some(k) = &f.kind {
146
        q = q.bind(k).bind(k);
147
    }
148
    if let Some(r) = f.repo_id {
149
        q = q.bind(r);
150
    }
151
    q.bind(limit).fetch_all(pool).await
152
}
153
154
/// Mark a thread read or unread, done or back in the inbox.
155
pub async fn set_thread(pool: &Pool, account_id: i64, thread: &str, read: Option<bool>, done: Option<bool>) -> sqlx::Result<()> {
156
    if let Some(r) = read {
157
        if r {
158
            sqlx::query("UPDATE notifications SET read = TRUE WHERE account_id = $1 AND thread = $2")
159
                .bind(account_id)
160
                .bind(thread)
161
                .execute(pool)
162
                .await?;
163
        } else {
164
            // Unread: the newest notification of the thread only.
165
            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)")
166
                .bind(account_id)
167
                .bind(thread)
168
                .execute(pool)
169
                .await?;
170
        }
171
    }
172
    if let Some(d) = done {
173
        let read_too = if d { ", read = TRUE" } else { "" };
174
        sqlx::query(&format!("UPDATE notifications SET done = $1{read_too} WHERE account_id = $2 AND thread = $3"))
175
            .bind(d)
176
            .bind(account_id)
177
            .bind(thread)
178
            .execute(pool)
179
            .await?;
180
    }
181
    Ok(())
182
}
183
184
/// The notification with this id, if it is the account's.
185
pub async fn get(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<Option<Notification>> {
186
    sqlx::query_as("SELECT * FROM notifications WHERE account_id = $1 AND id = $2").bind(account_id).bind(id).fetch_optional(pool).await
187
}
188
189
pub async fn inbox(pool: &Pool, account_id: i64, limit: i64) -> sqlx::Result<Vec<Notification>> {
190
    sqlx::query_as("SELECT * FROM notifications WHERE account_id = $1 ORDER BY read, at_ms DESC, id DESC LIMIT $2")
191
        .bind(account_id)
192
        .bind(limit)
193
        .fetch_all(pool)
194
        .await
195
}
196
197
pub async fn unread(pool: &Pool, account_id: i64) -> sqlx::Result<i64> {
198
    sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE account_id = $1 AND read = FALSE AND done = FALSE")
199
        .bind(account_id)
200
        .fetch_one(pool)
201
        .await
202
}
203
204
pub async fn mark_read(pool: &Pool, account_id: i64, id: Option<i64>) -> sqlx::Result<()> {
205
    match id {
206
        Some(id) => {
207
            sqlx::query("UPDATE notifications SET read = TRUE WHERE account_id = $1 AND id = $2")
208
                .bind(account_id)
209
                .bind(id)
210
                .execute(pool)
211
                .await?
212
        }
213
        None => sqlx::query("UPDATE notifications SET read = TRUE WHERE account_id = $1").bind(account_id).execute(pool).await?,
214
    };
215
    Ok(())
216
}
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).
223
pub async fn owners_of(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<i64>> {
224
    let owner: Option<String> =
225
        sqlx::query_scalar("SELECT owner FROM repositories WHERE id = $1").bind(repo_id).fetch_optional(pool).await?;
226
    let Some(owner) = owner else { return Ok(Vec::new()) };
227
    if let Some(a) = super::accounts::account_by_name(pool, &owner).await? {
228
        return Ok(vec![a.id]);
229
    }
230
    match super::accounts::org_by_name(pool, &owner).await? {
231
        Some(o) => {
232
            Ok(super::accounts::org_members(pool, o.id).await?.into_iter().filter(|(_, r)| r == "owner").map(|(a, _)| a.id).collect())
233
        }
234
        None => Ok(Vec::new()),
235
    }
236
}
237
238
/// Whether `account_id` watches `repo_id`: a watch it chose, or, for an
239
/// owner, unless it muted it.
240
pub async fn watching(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result<bool> {
241
    let muted: Option<bool> = sqlx::query_scalar("SELECT muted FROM watches WHERE account_id = $1 AND repo_id = $2")
242
        .bind(account_id)
243
        .bind(repo_id)
244
        .fetch_optional(pool)
245
        .await?;
246
    match muted {
247
        Some(m) => Ok(!m),
248
        None => Ok(owners_of(pool, repo_id).await?.contains(&account_id)),
249
    }
250
}
251
252
/// Watch `repo_id`, or stop: for an owner, stopping mutes it.
253
pub async fn set_watch(pool: &Pool, account_id: i64, repo_id: i64, on: bool) -> sqlx::Result<()> {
254
    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")
255
        .bind(account_id)
256
        .bind(repo_id)
257
        .bind(!on)
258
        .execute(pool)
259
        .await?;
260
    Ok(())
261
}
262
263
/// Everyone watching `repo_id`: those who chose to, and its owners who
264
/// have not muted it.
265
pub async fn watchers(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<i64>> {
266
    let rows: Vec<(i64, bool)> =
267
        sqlx::query_as("SELECT account_id, muted FROM watches WHERE repo_id = $1").bind(repo_id).fetch_all(pool).await?;
268
    let mut out: Vec<i64> = rows.iter().filter(|(_, m)| !m).map(|(a, _)| *a).collect();
269
    for o in owners_of(pool, repo_id).await? {
270
        if !rows.iter().any(|(a, _)| *a == o) && !out.contains(&o) {
271
            out.push(o);
272
        }
273
    }
274
    Ok(out)
275
}
276
277
pub async fn subscribe(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<()> {
278
    sqlx::query("INSERT INTO subscriptions (account_id, kind, target_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING")
279
        .bind(account_id)
280
        .bind(kind)
281
        .bind(target_id)
282
        .execute(pool)
283
        .await?;
284
    Ok(())
285
}
286
287
pub async fn unsubscribe(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<()> {
288
    sqlx::query("DELETE FROM subscriptions WHERE account_id = $1 AND kind = $2 AND target_id = $3")
289
        .bind(account_id)
290
        .bind(kind)
291
        .bind(target_id)
292
        .execute(pool)
293
        .await?;
294
    Ok(())
295
}
296
297
pub async fn subscribed(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<bool> {
298
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions WHERE account_id = $1 AND kind = $2 AND target_id = $3")
299
        .bind(account_id)
300
        .bind(kind)
301
        .bind(target_id)
302
        .fetch_one(pool)
303
        .await?;
304
    Ok(n > 0)
305
}
306
307
pub async fn subscribers(pool: &Pool, kind: &str, target_id: i64) -> sqlx::Result<Vec<i64>> {
308
    sqlx::query_scalar("SELECT account_id FROM subscriptions WHERE kind = $1 AND target_id = $2")
309
        .bind(kind)
310
        .bind(target_id)
311
        .fetch_all(pool)
312
        .await
313
}
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).
328
pub async fn index(pool: &Pool, kind: &str, repo_id: i64, target_id: i64, number: i64, title: &str, body: &str) -> sqlx::Result<()> {
329
    sqlx::query("DELETE FROM search WHERE kind = $1 AND target_id = $2").bind(kind).bind(target_id).execute(pool).await?;
330
    sqlx::query("INSERT INTO search (kind, repo_id, target_id, number, title, body) VALUES ($1, $2, $3, $4, $5, $6)")
331
        .bind(kind)
332
        .bind(repo_id)
333
        .bind(target_id)
334
        .bind(number)
335
        .bind(title)
336
        .bind(body)
337
        .execute(pool)
338
        .await?;
339
    Ok(())
340
}
341
342
/// Full-text hits, best first. The query is quoted so punctuation cannot
343
/// reach the FTS5 parser.
344
#[cfg(not(feature = "postgres"))]
345
pub async fn search(pool: &Pool, query: &str, repo_id: Option<i64>, limit: i64) -> sqlx::Result<Vec<Hit>> {
346
    let terms: Vec<String> = query.split_whitespace().map(|t| format!("\"{}\"", t.replace('"', ""))).collect();
347
    if terms.is_empty() {
348
        return Ok(vec![]);
349
    }
350
    let q = terms.join(" ");
351
    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";
352
    sqlx::query_as(sql).bind(q).bind(repo_id).bind(repo_id).bind(limit).fetch_all(pool).await
353
}
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
}