tirbofish/kitgit · commit
a0369aaad4cae6ed4c7b72e61de6bbaa2c929b1c
feat: add in-app notifications for watched repos
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -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; @@ -234,6 +234,19 @@ pub struct CommitDay { pub count: i32, } +#[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 { @@ -2349,3 +2349,173 @@ pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> { .await?; Ok(row.map(|r| r.0)) } + +// ── 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()) +} @@ -88,6 +88,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", @@ -1525,6 +1525,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( @@ -2638,7 +2692,7 @@ pub async fn issue_create( } let body = form.body.unwrap_or_default(); let number = queries::next_issue_number(&state.pool, repository.id).await?; - let issue = + let _issue = queries::create_issue(&state.pool, repository.id, user.id, number, title, &body).await?; queries::record_activity( &state.pool, @@ -2649,10 +2703,18 @@ pub async fn issue_create( serde_json::json!({ "number": number }), ) .await?; - 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), + ¬if_snippet(&body, 160), + &href, + ) + .await?; + Ok(redirect_see_other(&href)) } pub async fn issue_view( @@ -2732,9 +2794,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), + ¬if_snippet(body, 160), + &href, + Some(repository.id), + ) + .await?; + Ok(redirect_see_other(&href)) } pub async fn issue_close( @@ -2884,7 +2957,7 @@ pub async fn pull_create( } let body = form.body.unwrap_or_default(); let number = queries::next_pull_number(&state.pool, repository.id).await?; - let pull = queries::create_pull( + let _pull = queries::create_pull( &state.pool, repository.id, user.id, @@ -2904,10 +2977,18 @@ pub async fn pull_create( serde_json::json!({ "number": number }), ) .await?; - 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), + ¬if_snippet(&body, 160), + &href, + ) + .await?; + Ok(redirect_see_other(&href)) } #[derive(Deserialize)] @@ -3032,7 +3113,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), + ¬if_snippet(body, 160), + &href, + Some(repository.id), + ) + .await?; + Ok(redirect_see_other(&href)) } #[derive(Deserialize)] @@ -3316,10 +3410,20 @@ pub async fn release_create( serde_json::json!({ "tag": tag }), ) .await?; - 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( @@ -3441,6 +3545,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(); @@ -3535,10 +3640,20 @@ pub async fn release_update( serde_json::json!({ "tag": updated.tag_name }), ) .await?; - 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( @@ -1,7 +1,7 @@ use crate::og::SocialMeta; use crate::db::models::{ - Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, Repository, SshKey, User, - UserEmail, + Access, BranchRule, CommitDay, GpgKey, Issue, Notification, PullRequest, Release, Repository, + SshKey, User, UserEmail, }; use askama::Template; use askama_web::WebTemplate; @@ -573,3 +573,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, +} @@ -1552,3 +1552,41 @@ html.kg-dark .kg-codeview .punctuation { color: #b0b0b0; } 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; +} @@ -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> @@ -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; @@ -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 %}