kitgit

tirbofish/kitgit · diff

a64ae95 · Thribhu K

merge: feat/notifications into integrate/all-features

Verified

diff --git a/migrations/016_notifications.sql b/migrations/016_notifications.sql
new file mode 100644
index 0000000..0439544
--- /dev/null
+++ b/migrations/016_notifications.sql
@@ -0,0 +1,20 @@
+-- In-app notifications for watched repos and thread participants
+
+CREATE TABLE IF NOT EXISTS notifications (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    kind TEXT NOT NULL,
+    title TEXT NOT NULL,
+    body TEXT NOT NULL DEFAULT '',
+    href TEXT NOT NULL DEFAULT '',
+    repo_id UUID REFERENCES repositories(id) ON DELETE SET NULL,
+    read_at TIMESTAMPTZ,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS notifications_user_id_created_at_idx
+    ON notifications (user_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS notifications_user_id_unread_idx
+    ON notifications (user_id)
+    WHERE read_at IS NULL;
diff --git a/src/db/models.rs b/src/db/models.rs
index 762208b..d119230 100644
--- a/src/db/models.rs
+++ b/src/db/models.rs
@@ -295,6 +295,19 @@ pub struct WebhookDelivery {
     pub created_at: DateTime<Utc>,
 }
 
+#[derive(Debug, Clone, FromRow, Serialize)]
+pub struct Notification {
+    pub id: Uuid,
+    pub user_id: Uuid,
+    pub kind: String,
+    pub title: String,
+    pub body: String,
+    pub href: String,
+    pub repo_id: Option<Uuid>,
+    pub read_at: Option<DateTime<Utc>>,
+    pub created_at: DateTime<Utc>,
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "lowercase")]
 pub enum Access {
diff --git a/src/db/queries.rs b/src/db/queries.rs
index 9f62018..8e7b51a 100644
--- a/src/db/queries.rs
+++ b/src/db/queries.rs
@@ -2652,3 +2652,173 @@ pub async fn list_recent_webhook_deliveries(
         })
         .collect())
 }
+
+// ── notifications ────────────────────────────────────────────────────────────
+
+pub async fn create_notification(
+    pool: &PgPool,
+    user_id: Uuid,
+    kind: &str,
+    title: &str,
+    body: &str,
+    href: &str,
+    repo_id: Option<Uuid>,
+) -> Result<Notification> {
+    Ok(sqlx::query_as::<_, Notification>(
+        r#"
+        INSERT INTO notifications (user_id, kind, title, body, href, repo_id)
+        VALUES ($1, $2, $3, $4, $5, $6)
+        RETURNING *
+        "#,
+    )
+    .bind(user_id)
+    .bind(kind)
+    .bind(title)
+    .bind(body)
+    .bind(href)
+    .bind(repo_id)
+    .fetch_one(pool)
+    .await?)
+}
+
+/// Fan out the same notification to many users, skipping `exclude_user_id`.
+pub async fn notify_users(
+    pool: &PgPool,
+    user_ids: &[Uuid],
+    exclude_user_id: Uuid,
+    kind: &str,
+    title: &str,
+    body: &str,
+    href: &str,
+    repo_id: Option<Uuid>,
+) -> Result<u64> {
+    let mut created = 0u64;
+    for &uid in user_ids {
+        if uid == exclude_user_id {
+            continue;
+        }
+        create_notification(pool, uid, kind, title, body, href, repo_id).await?;
+        created += 1;
+    }
+    Ok(created)
+}
+
+pub async fn list_repo_watcher_ids(pool: &PgPool, repo_id: Uuid) -> Result<Vec<Uuid>> {
+    let rows: Vec<(Uuid,)> =
+        sqlx::query_as("SELECT user_id FROM repo_watches WHERE repo_id = $1")
+            .bind(repo_id)
+            .fetch_all(pool)
+            .await?;
+    Ok(rows.into_iter().map(|r| r.0).collect())
+}
+
+pub async fn notify_repo_watchers(
+    pool: &PgPool,
+    repo_id: Uuid,
+    exclude_user_id: Uuid,
+    kind: &str,
+    title: &str,
+    body: &str,
+    href: &str,
+) -> Result<u64> {
+    let watchers = list_repo_watcher_ids(pool, repo_id).await?;
+    notify_users(
+        pool,
+        &watchers,
+        exclude_user_id,
+        kind,
+        title,
+        body,
+        href,
+        Some(repo_id),
+    )
+    .await
+}
+
+/// Issue author plus prior comment authors.
+pub async fn list_issue_participant_ids(pool: &PgPool, issue_id: Uuid) -> Result<Vec<Uuid>> {
+    let rows: Vec<(Uuid,)> = sqlx::query_as(
+        r#"
+        SELECT author_id FROM issues WHERE id = $1
+        UNION
+        SELECT DISTINCT author_id FROM comments WHERE issue_id = $1
+        "#,
+    )
+    .bind(issue_id)
+    .fetch_all(pool)
+    .await?;
+    Ok(rows.into_iter().map(|r| r.0).collect())
+}
+
+/// Pull author plus prior comment authors.
+pub async fn list_pull_participant_ids(pool: &PgPool, pull_id: Uuid) -> Result<Vec<Uuid>> {
+    let rows: Vec<(Uuid,)> = sqlx::query_as(
+        r#"
+        SELECT author_id FROM pull_requests WHERE id = $1
+        UNION
+        SELECT DISTINCT author_id FROM comments WHERE pull_id = $1
+        "#,
+    )
+    .bind(pull_id)
+    .fetch_all(pool)
+    .await?;
+    Ok(rows.into_iter().map(|r| r.0).collect())
+}
+
+pub async fn list_notifications(
+    pool: &PgPool,
+    user_id: Uuid,
+    limit: i64,
+) -> Result<Vec<Notification>> {
+    Ok(sqlx::query_as::<_, Notification>(
+        r#"
+        SELECT * FROM notifications
+        WHERE user_id = $1
+        ORDER BY created_at DESC
+        LIMIT $2
+        "#,
+    )
+    .bind(user_id)
+    .bind(limit)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn unread_notification_count(pool: &PgPool, user_id: Uuid) -> Result<i64> {
+    let (n,): (i64,) = sqlx::query_as(
+        "SELECT COUNT(*)::bigint FROM notifications WHERE user_id = $1 AND read_at IS NULL",
+    )
+    .bind(user_id)
+    .fetch_one(pool)
+    .await?;
+    Ok(n)
+}
+
+pub async fn mark_notification_read(
+    pool: &PgPool,
+    id: Uuid,
+    user_id: Uuid,
+) -> Result<Option<Notification>> {
+    Ok(sqlx::query_as::<_, Notification>(
+        r#"
+        UPDATE notifications
+        SET read_at = COALESCE(read_at, now())
+        WHERE id = $1 AND user_id = $2
+        RETURNING *
+        "#,
+    )
+    .bind(id)
+    .bind(user_id)
+    .fetch_optional(pool)
+    .await?)
+}
+
+pub async fn mark_all_notifications_read(pool: &PgPool, user_id: Uuid) -> Result<u64> {
+    let res = sqlx::query(
+        "UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL",
+    )
+    .bind(user_id)
+    .execute(pool)
+    .await?;
+    Ok(res.rows_affected())
+}
diff --git a/src/web/mod.rs b/src/web/mod.rs
index f6e7a74..fd46636 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -89,6 +89,19 @@ pub fn app_router(state: AppState) -> Router {
         )
         .route("/admin/repos/{id}/delete", post(routes::admin_repo_delete))
         .route("/site-banner.json", get(routes::site_banner_json))
+        .route("/notifications", get(routes::notifications_list))
+        .route(
+            "/notifications/unread.json",
+            get(routes::notifications_unread_json),
+        )
+        .route(
+            "/notifications/read-all",
+            post(routes::notifications_mark_all_read),
+        )
+        .route(
+            "/notifications/{id}/read",
+            post(routes::notifications_mark_read),
+        )
         .route("/new", get(routes::new_repo_form).post(routes::new_repo))
         .route(
             "/settings/profile",
diff --git a/src/web/routes.rs b/src/web/routes.rs
index 22bc8df..d1aed00 100644
--- a/src/web/routes.rs
+++ b/src/web/routes.rs
@@ -1688,6 +1688,60 @@ pub async fn site_banner_json(State(state): State<AppState>) -> impl IntoRespons
     axum::Json(serde_json::json!({ "message": message }))
 }
 
+fn notif_snippet(body: &str, max: usize) -> String {
+    let compact: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
+    if compact.chars().count() <= max {
+        compact
+    } else {
+        let truncated: String = compact.chars().take(max.saturating_sub(1)).collect();
+        format!("{truncated}…")
+    }
+}
+
+pub async fn notifications_list(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<impl IntoResponse> {
+    let viewer = require_login(&state.auth, &headers).await?;
+    let notifications = queries::list_notifications(&state.pool, viewer.id, 100).await?;
+    let unread_count = queries::unread_notification_count(&state.pool, viewer.id).await?;
+    Ok(NotificationsTemplate {
+        viewer: Some(viewer),
+        notifications,
+        unread_count,
+    })
+}
+
+pub async fn notifications_unread_json(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<impl IntoResponse> {
+    let viewer = require_login(&state.auth, &headers).await?;
+    let count = queries::unread_notification_count(&state.pool, viewer.id).await?;
+    Ok(axum::Json(serde_json::json!({ "count": count })))
+}
+
+pub async fn notifications_mark_read(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(id): Path<Uuid>,
+) -> AppResult<Response> {
+    let viewer = require_login(&state.auth, &headers).await?;
+    queries::mark_notification_read(&state.pool, id, viewer.id)
+        .await?
+        .ok_or_else(AppError::not_found)?;
+    Ok(redirect_see_other("/notifications"))
+}
+
+pub async fn notifications_mark_all_read(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<Response> {
+    let viewer = require_login(&state.auth, &headers).await?;
+    queries::mark_all_notifications_read(&state.pool, viewer.id).await?;
+    Ok(redirect_see_other("/notifications"))
+}
+
 // ── new repo ─────────────────────────────────────────────────────────────────
 
 pub async fn new_repo_form(
@@ -2844,10 +2898,18 @@ pub async fn issue_create(
             "state": issue.state,
         }),
     );
-    Ok(redirect_see_other(&format!(
-        "/{owner}/{repo}/issues/{}",
-        issue.number
-    )))
+    let href = format!("/{owner}/{repo}/issues/{number}");
+    queries::notify_repo_watchers(
+        &state.pool,
+        repository.id,
+        user.id,
+        "issue.open",
+        &format!("{} opened issue #{number}: {title}", user.username),
+        &notif_snippet(&body, 160),
+        &href,
+    )
+    .await?;
+    Ok(redirect_see_other(&href))
 }
 
 pub async fn issue_view(
@@ -2927,9 +2989,20 @@ pub async fn issue_comment(
         serde_json::json!({ "number": number }),
     )
     .await?;
-    Ok(redirect_see_other(&format!(
-        "/{owner}/{repo}/issues/{number}"
-    )))
+    let participants = queries::list_issue_participant_ids(&state.pool, issue.id).await?;
+    let href = format!("/{owner}/{repo}/issues/{number}");
+    queries::notify_users(
+        &state.pool,
+        &participants,
+        user.id,
+        "issue.comment",
+        &format!("{} commented on issue #{number}", user.username),
+        &notif_snippet(body, 160),
+        &href,
+        Some(repository.id),
+    )
+    .await?;
+    Ok(redirect_see_other(&href))
 }
 
 pub async fn issue_close(
@@ -3143,10 +3216,18 @@ pub async fn pull_create(
             "target_branch": pull.target_branch,
         }),
     );
-    Ok(redirect_see_other(&format!(
-        "/{owner}/{repo}/pulls/{}",
-        pull.number
-    )))
+    let href = format!("/{owner}/{repo}/pulls/{number}");
+    queries::notify_repo_watchers(
+        &state.pool,
+        repository.id,
+        user.id,
+        "pull.open",
+        &format!("{} opened pull #{number}: {title}", user.username),
+        &notif_snippet(&body, 160),
+        &href,
+    )
+    .await?;
+    Ok(redirect_see_other(&href))
 }
 
 #[derive(Deserialize)]
@@ -3271,7 +3352,20 @@ pub async fn pull_comment(
         serde_json::json!({ "number": number }),
     )
     .await?;
-    Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}")))
+    let participants = queries::list_pull_participant_ids(&state.pool, pull.id).await?;
+    let href = format!("/{owner}/{repo}/pulls/{number}");
+    queries::notify_users(
+        &state.pool,
+        &participants,
+        user.id,
+        "pull.comment",
+        &format!("{} commented on pull #{number}", user.username),
+        &notif_snippet(body, 160),
+        &href,
+        Some(repository.id),
+    )
+    .await?;
+    Ok(redirect_see_other(&href))
 }
 
 #[derive(Deserialize)]
@@ -3603,10 +3697,20 @@ pub async fn release_create(
             "is_draft": release.is_draft,
         }),
     );
-    Ok(redirect_see_other(&format!(
-        "/{owner}/{repo}/releases/{}",
-        release.tag_name
-    )))
+    let href = format!("/{owner}/{repo}/releases/{}", release.tag_name);
+    if !is_draft {
+        queries::notify_repo_watchers(
+            &state.pool,
+            repository.id,
+            user.id,
+            "release.publish",
+            &format!("{} published release {}", user.username, release.tag_name),
+            &title,
+            &href,
+        )
+        .await?;
+    }
+    Ok(redirect_see_other(&href))
 }
 
 pub async fn release_view(
@@ -3728,6 +3832,7 @@ pub async fn release_update(
     let release = queries::get_release(&state.pool, repository.id, &tag)
         .await?
         .ok_or_else(AppError::not_found)?;
+    let was_draft = release.is_draft;
 
     let new_tag = form.tag_name.trim().to_string();
     let title = form.title.trim().to_string();
@@ -3837,10 +3942,20 @@ pub async fn release_update(
             "is_draft": updated.is_draft,
         }),
     );
-    Ok(redirect_see_other(&format!(
-        "/{owner}/{repo}/releases/{}",
-        updated.tag_name
-    )))
+    let href = format!("/{owner}/{repo}/releases/{}", updated.tag_name);
+    if was_draft && !updated.is_draft {
+        queries::notify_repo_watchers(
+            &state.pool,
+            repository.id,
+            user.id,
+            "release.publish",
+            &format!("{} published release {}", user.username, updated.tag_name),
+            &updated.title,
+            &href,
+        )
+        .await?;
+    }
+    Ok(redirect_see_other(&href))
 }
 
 pub async fn release_delete(
diff --git a/src/web/templates.rs b/src/web/templates.rs
index 5731055..3bb33ab 100644
--- a/src/web/templates.rs
+++ b/src/web/templates.rs
@@ -1,7 +1,7 @@
 use crate::og::SocialMeta;
 use crate::db::models::{
-    Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, RepoMirror, Repository,
-    SshKey, User, UserEmail,
+    Access, BranchRule, CommitDay, GpgKey, Issue, Notification, PullRequest, Release, RepoMirror,
+    Repository, SshKey, User, UserEmail,
 };
 use crate::db::DeployKey;
 use askama::Template;
@@ -614,3 +614,11 @@ pub struct AdminStatsView {
     pub active_invites: i64,
     pub disk_label: String,
 }
+
+#[derive(Template, WebTemplate)]
+#[template(path = "notifications.html")]
+pub struct NotificationsTemplate {
+    pub viewer: Option<User>,
+    pub notifications: Vec<Notification>,
+    pub unread_count: i64,
+}
diff --git a/static/brand.css b/static/brand.css
index 890e5d7..0703110 100644
--- a/static/brand.css
+++ b/static/brand.css
@@ -1607,3 +1607,41 @@ a:hover {
     animation: none !important;
   }
 }
+
+.kg-notif-count {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 1.25rem;
+  height: 1.25rem;
+  padding: 0 0.35em;
+  margin-left: 0.25em;
+  border-radius: 999px;
+  background: var(--kg-invert-bg);
+  color: var(--kg-invert-fg);
+  font-size: 0.65rem;
+  font-weight: 700;
+  line-height: 1;
+  vertical-align: middle;
+}
+
+.kg-notif-list li {
+  align-items: flex-start;
+}
+
+.kg-notif-main {
+  display: flex;
+  flex-direction: column;
+  gap: 0.2rem;
+  min-width: 0;
+}
+
+.kg-notif-actions {
+  flex-shrink: 0;
+}
+
+.kg-notif--unread {
+  border-left: 2px solid var(--kg-fg);
+  padding-left: 0.65rem;
+  margin-left: -0.65rem;
+}
diff --git a/static/icons/bell.svg b/static/icons/bell.svg
new file mode 100644
index 0000000..142388b
--- /dev/null
+++ b/static/icons/bell.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>
diff --git a/templates/layout.html b/templates/layout.html
index 169ef58..741a883 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -23,6 +23,11 @@
         <a href="/explore"><span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span> explore</a>
         {% if let Some(u) = viewer %}
           <a href="/new"><span class="kg-icon" style="--icon:url('/static/icons/plus.svg')"></span> new</a>
+          <a id="kg-nav-notifications" href="/notifications">
+            <span class="kg-icon" style="--icon:url('/static/icons/bell.svg')"></span>
+            notifications
+            <span id="kg-notif-count" class="kg-notif-count" hidden></span>
+          </a>
           {% if u.is_site_admin %}<a href="/admin"><span class="kg-icon" style="--icon:url('/static/icons/settings.svg')"></span> admin</a>{% endif %}
           <a href="/settings/profile"><span class="kg-icon" style="--icon:url('/static/icons/user.svg')"></span> settings</a>
           <a class="kg-userchip" href="/{{ u.username }}">
@@ -54,6 +59,19 @@
       }
     } catch (_) {}
   })();
+  (async () => {
+    const badge = document.getElementById('kg-notif-count');
+    if (!badge) return;
+    try {
+      const r = await fetch('/notifications/unread.json', { credentials: 'same-origin' });
+      if (!r.ok) return;
+      const j = await r.json();
+      const n = Number(j && j.count);
+      if (!n) return;
+      badge.textContent = n > 99 ? '99+' : String(n);
+      badge.hidden = false;
+    } catch (_) {}
+  })();
   document.addEventListener('click', (e) => {
     const btn = e.target.closest('[data-copy]');
     if (!btn) return;
diff --git a/templates/notifications.html b/templates/notifications.html
new file mode 100644
index 0000000..59c90c1
--- /dev/null
+++ b/templates/notifications.html
@@ -0,0 +1,47 @@
+{% extends "layout.html" %}
+
+{% block title %}notifications — kitgit{% endblock %}
+
+{% block content %}
+<section class="kg-section">
+  <div class="kg-actions" style="justify-content: space-between; align-items: baseline;">
+    <h1 class="kg-title">notifications</h1>
+    {% if unread_count > 0 %}
+      <form method="post" action="/notifications/read-all">
+        <button class="kg-btn kg-btn--ghost" type="submit">mark all read</button>
+      </form>
+    {% endif %}
+  </div>
+
+  {% if notifications.is_empty() %}
+    <p class="kg-muted">No notifications yet.</p>
+  {% else %}
+    <ul class="kg-list kg-notif-list">
+      {% for n in notifications %}
+      <li class="{% if n.read_at.is_none() %}kg-notif--unread{% endif %}">
+        <span class="kg-notif-main">
+          {% if n.href.is_empty() %}
+            <strong>{{ n.title }}</strong>
+          {% else %}
+            <a href="{{ n.href }}"><strong>{{ n.title }}</strong></a>
+          {% endif %}
+          {% if !n.body.is_empty() %}
+            <span class="kg-muted">{{ n.body }}</span>
+          {% endif %}
+          <span class="kg-meta">{{ n.created_at }} · {{ n.kind }}</span>
+        </span>
+        <span class="kg-notif-actions">
+          {% if n.read_at.is_none() %}
+            <form method="post" action="/notifications/{{ n.id }}/read">
+              <button class="kg-btn kg-btn--ghost" type="submit">mark read</button>
+            </form>
+          {% else %}
+            <span class="kg-faint">read</span>
+          {% endif %}
+        </span>
+      </li>
+      {% endfor %}
+    </ul>
+  {% endif %}
+</section>
+{% endblock %}