//! Notifications, watching, subscriptions and the search index (hub spec //! §10, §11). use super::{now_ms, Pool}; use sqlx::FromRow; #[derive(Clone, Debug, FromRow)] pub struct Notification { pub id: i64, pub account_id: i64, pub at_ms: i64, pub kind: String, pub title: String, pub url: String, pub actor: String, pub read: bool, pub repo_id: Option, pub thread: String, pub reason: String, pub done: bool, } /// Why someone is told: they take part in the thread, or only watch the repository. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Reason { Participating, Watching, } impl Reason { pub fn as_str(self) -> &'static str { match self { Reason::Participating => "participating", Reason::Watching => "watching", } } } /// The thread a notification belongs to: its page, without a fragment. pub fn thread_of(url: &str) -> &str { url.split('#').next().unwrap_or(url) } /// A notification to record. pub struct New<'a> { pub account_id: i64, pub repo_id: Option, pub reason: Reason, pub kind: &'a str, pub title: &'a str, pub url: &'a str, pub actor: &'a str, } pub async fn add(pool: &Pool, n: New<'_>) -> sqlx::Result<()> { let New { account_id, repo_id, reason, kind, title, url, actor } = n; // A new event brings a thread that was set aside back to the inbox. sqlx::query("UPDATE notifications SET done = 0 WHERE account_id = ? AND thread = ? AND done = 1") .bind(account_id) .bind(thread_of(url)) .execute(pool) .await?; sqlx::query("INSERT INTO notifications (account_id, at_ms, kind, title, url, actor, repo_id, thread, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") .bind(account_id) .bind(now_ms()) .bind(kind) .bind(title) .bind(url) .bind(actor) .bind(repo_id) .bind(thread_of(url)) .bind(reason.as_str()) .execute(pool) .await?; Ok(()) } /// One inbox row: a thread, shown by its newest notification. #[derive(Clone, Debug, FromRow)] pub struct Thread { /// The newest notification's id. pub id: i64, pub at_ms: i64, pub kind: String, pub title: String, pub url: String, pub actor: String, pub thread: String, pub repo_id: Option, pub repo: Option, pub count: i64, pub unread: i64, pub participating: bool, pub done: bool, } /// What the inbox shows. #[derive(Clone, Debug, Default)] pub struct Filter { pub done: bool, pub unread_only: bool, /// `participating` or `watching`. pub reason: Option, /// A kind or its family (`issue` matches `issue.closed`). pub kind: Option, pub repo_id: Option, } pub async fn threads(pool: &Pool, account_id: i64, f: &Filter, limit: i64) -> sqlx::Result> { // SQLite takes bare columns from the row holding MAX(at_ms): the newest. let mut sql = String::from( "SELECT n.id, MAX(n.at_ms) AS at_ms, n.kind, n.title, n.url, n.actor, n.thread, n.repo_id, \ (SELECT r.owner || '/' || r.name FROM repositories r WHERE r.id = n.repo_id) AS repo, \ COUNT(*) AS count, SUM(n.read = 0) AS unread, SUM(n.reason = 'participating') > 0 AS participating, n.done \ FROM notifications n WHERE n.account_id = ? AND n.done = ?", ); if f.kind.is_some() { sql.push_str(" AND (n.kind = ? OR n.kind LIKE ? || '.%')"); } if f.repo_id.is_some() { sql.push_str(" AND n.repo_id = ?"); } sql.push_str(" GROUP BY n.thread HAVING 1"); if f.unread_only { sql.push_str(" AND SUM(n.read = 0) > 0"); } match f.reason.as_deref() { Some("participating") => sql.push_str(" AND SUM(n.reason = 'participating') > 0"), Some("watching") => sql.push_str(" AND SUM(n.reason = 'participating') = 0"), _ => {} } sql.push_str(" ORDER BY at_ms DESC LIMIT ?"); let mut q = sqlx::query_as::<_, Thread>(&sql).bind(account_id).bind(f.done); if let Some(k) = &f.kind { q = q.bind(k).bind(k); } if let Some(r) = f.repo_id { q = q.bind(r); } q.bind(limit).fetch_all(pool).await } /// Mark a thread read or unread, done or back in the inbox. pub async fn set_thread(pool: &Pool, account_id: i64, thread: &str, read: Option, done: Option) -> sqlx::Result<()> { if let Some(r) = read { if r { sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = ? AND thread = ?") .bind(account_id) .bind(thread) .execute(pool) .await?; } else { // Unread: the newest notification of the thread only. 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)") .bind(account_id) .bind(thread) .execute(pool) .await?; } } if let Some(d) = done { let read_too = if d { ", read = 1" } else { "" }; sqlx::query(&format!("UPDATE notifications SET done = ?{read_too} WHERE account_id = ? AND thread = ?")) .bind(d) .bind(account_id) .bind(thread) .execute(pool) .await?; } Ok(()) } /// The notification with this id, if it is the account's. pub async fn get(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result> { sqlx::query_as("SELECT * FROM notifications WHERE account_id = ? AND id = ?").bind(account_id).bind(id).fetch_optional(pool).await } pub async fn inbox(pool: &Pool, account_id: i64, limit: i64) -> sqlx::Result> { sqlx::query_as("SELECT * FROM notifications WHERE account_id = ? ORDER BY read, at_ms DESC, id DESC LIMIT ?") .bind(account_id) .bind(limit) .fetch_all(pool) .await } pub async fn unread(pool: &Pool, account_id: i64) -> sqlx::Result { sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE account_id = ? AND read = 0 AND done = 0") .bind(account_id) .fetch_one(pool) .await } pub async fn mark_read(pool: &Pool, account_id: i64, id: Option) -> sqlx::Result<()> { match id { Some(id) => { sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = ? AND id = ?").bind(account_id).bind(id).execute(pool).await? } None => sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = ?").bind(account_id).execute(pool).await?, }; Ok(()) } // ---------------------------------------------------------------- watching pub async fn watching(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result { let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM watches WHERE account_id = ? AND repo_id = ?") .bind(account_id) .bind(repo_id) .fetch_one(pool) .await?; Ok(n > 0) } pub async fn set_watch(pool: &Pool, account_id: i64, repo_id: i64, on: bool) -> sqlx::Result<()> { if on { sqlx::query("INSERT OR IGNORE INTO watches (account_id, repo_id) VALUES (?, ?)") .bind(account_id) .bind(repo_id) .execute(pool) .await?; } else { sqlx::query("DELETE FROM watches WHERE account_id = ? AND repo_id = ?").bind(account_id).bind(repo_id).execute(pool).await?; } Ok(()) } pub async fn watchers(pool: &Pool, repo_id: i64) -> sqlx::Result> { sqlx::query_scalar("SELECT account_id FROM watches WHERE repo_id = ?").bind(repo_id).fetch_all(pool).await } pub async fn subscribe(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<()> { sqlx::query("INSERT OR IGNORE INTO subscriptions (account_id, kind, target_id) VALUES (?, ?, ?)") .bind(account_id) .bind(kind) .bind(target_id) .execute(pool) .await?; Ok(()) } pub async fn unsubscribe(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<()> { sqlx::query("DELETE FROM subscriptions WHERE account_id = ? AND kind = ? AND target_id = ?") .bind(account_id) .bind(kind) .bind(target_id) .execute(pool) .await?; Ok(()) } pub async fn subscribed(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result { let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions WHERE account_id = ? AND kind = ? AND target_id = ?") .bind(account_id) .bind(kind) .bind(target_id) .fetch_one(pool) .await?; Ok(n > 0) } pub async fn subscribers(pool: &Pool, kind: &str, target_id: i64) -> sqlx::Result> { sqlx::query_scalar("SELECT account_id FROM subscriptions WHERE kind = ? AND target_id = ?") .bind(kind) .bind(target_id) .fetch_all(pool) .await } // ------------------------------------------------------------------ search #[derive(Clone, Debug, FromRow)] pub struct Hit { pub kind: String, pub repo_id: i64, pub target_id: i64, pub number: i64, pub title: String, pub snippet: String, } /// Replace the indexed text of one thread (title plus body plus comments). pub async fn index(pool: &Pool, kind: &str, repo_id: i64, target_id: i64, number: i64, title: &str, body: &str) -> sqlx::Result<()> { sqlx::query("DELETE FROM search WHERE kind = ? AND target_id = ?").bind(kind).bind(target_id).execute(pool).await?; sqlx::query("INSERT INTO search (kind, repo_id, target_id, number, title, body) VALUES (?, ?, ?, ?, ?, ?)") .bind(kind) .bind(repo_id) .bind(target_id) .bind(number) .bind(title) .bind(body) .execute(pool) .await?; Ok(()) } /// Full-text hits, best first. The query is quoted so punctuation cannot /// reach the FTS5 parser. pub async fn search(pool: &Pool, query: &str, repo_id: Option, limit: i64) -> sqlx::Result> { let terms: Vec = query.split_whitespace().map(|t| format!("\"{}\"", t.replace('"', ""))).collect(); if terms.is_empty() { return Ok(vec![]); } let q = terms.join(" "); let sql = "SELECT kind, repo_id, target_id, number, title, snippet(search, 5, '', '', '…', 12) AS snippet FROM search WHERE search MATCH ? AND (? IS NULL OR repo_id = ?) ORDER BY rank LIMIT ?"; sqlx::query_as(sql).bind(q).bind(repo_id).bind(repo_id).bind(limit).fetch_all(pool).await }