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