# Notifications (contract between workers) The global rules from the earlier interface docs apply: - No JS and no inline styles; no unwrap in non-test code. - Tests use `tempfile::TempDir`. - Never put cargo target dirs or big files in /tmp. ## Model - **One notification thread per (user, issue-or-PR).** New activity on a thread upserts its row: - `unread = 1`, `updated_at = now`, `reason` = the strongest current reason, `last_actor_id`. - Reason strength, highest first: `mention` > `assign` > `review_requested` > `author` > `comment` > `state_change` > `subscribed` > `watching`. - **Who gets notified** about activity on issue I in repo R (never the actor themself; only users who can Read R *now*, checked with `authorize` using their collaborator role): 1. users subscribed to I (`issue_subscriptions.state = 'subscribed'`), with their stored reason; 2. users watching R with level `watching`, unless they have an `ignored` subscription on I; 3. users @mentioned in the new text who can read R. This auto-subscribes them with reason `mention`. - **Auto-subscribe** (state subscribed, if no row exists yet): - the author on create; - a commenter on comment; - an assignee on assign; - a reviewer on review submit; - anyone mentioned. An explicit `ignored` row is never overwritten by auto-subscribe. - **Repo watch levels:** - `watching`: all activity; - `participating`: the default when no row exists; subscriptions only; - `ignoring`: no notifications from this repo at all, even when subscribed. **Mentions still notify.** - **Activities:** - `opened`, `comment`, `closed`, `reopened`, `merged`; - `review` (with `state`), `assigned` (to that user: reason `assign`); - `mentioned`, `pushed` (synchronize on a PR: subscribers only, not watchers). ## Schema (migration `0012_notifications.sql`) ```sql CREATE TABLE repo_watches (user_id TEXT NOT NULL REFERENCES users(id), repo_id TEXT NOT NULL REFERENCES repos(id), level TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, repo_id)); CREATE TABLE issue_subscriptions (user_id TEXT NOT NULL REFERENCES users(id), issue_id TEXT NOT NULL REFERENCES issues(id), state TEXT NOT NULL, reason TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, issue_id)); CREATE TABLE notifications (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), repo_id TEXT NOT NULL REFERENCES repos(id), issue_id TEXT NOT NULL REFERENCES issues(id), reason TEXT NOT NULL, last_activity TEXT NOT NULL, last_actor_id TEXT REFERENCES users(id), unread INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL, UNIQUE (user_id, issue_id)); CREATE INDEX notifications_inbox ON notifications(user_id, unread, updated_at); ``` - `Store::delete_repo` must delete these rows for the repo and its issues. - `delete_user` (store/user_delete.rs) must delete the user's rows. In `notifications.last_actor_id` it reassigns the user to ghost. ## forge-core (`src/notifications.rs` model plus `store/notifications.rs`) ```rust pub enum WatchLevel { Watching, Participating, Ignoring } pub enum SubState { Subscribed, Ignored } pub enum Reason { Mention, Assign, ReviewRequested, Author, Comment, StateChange, Subscribed, Watching } // as_str/parse, rank() pub struct Notification { pub id: Id, pub repo_id: Id, pub repo_owner: String, pub repo_name: String, pub issue_id: Id, pub number: i64, pub is_pull: bool, pub title: String, pub issue_state: String, pub reason: Reason, pub last_activity: String, pub last_actor_name: Option, pub unread: bool, pub updated_at: String } set_watch(user, repo, WatchLevel); watch_level(user, repo) -> WatchLevel; watchers(repo) -> Vec // level watching subscribe(user, issue, SubState, Reason); auto_subscribe(user, issue, Reason) // no-op if a row exists subscription(user, issue) -> Option<(SubState, Reason)>; subscribers(issue) -> Vec<(Id, Reason)> // state subscribed upsert_notification(user, repo, issue, reason, activity, actor) // keeps the stronger reason if one exists and the row is still unread list_notifications(user, unread_only: bool, before: Option<&str>, limit) -> Vec unread_count(user) -> u64 mark_read(user, notification_id); mark_all_read(user, repo: Option<&str>); mark_issue_read(user, issue) // the last one is called when the user views the issue or PR ``` ## forge-web - `crate::notify::dispatch(state, repo, issue, actor_id, activity, text_for_mentions: Option<&str>)`: - computes recipients per the rules above, then auto-subscribes and upserts; - never fails the caller (it logs errors); - runs **inline** (DB only, cheap). - **Mentions:** extract `@username` with the same rules as the markdown `@user` linking (reuse that code: grep `render/markdown.rs` for the mention handling and factor a `pub fn mentioned_usernames(text) -> Vec`). Skip code spans and code blocks, and dedupe. - **Call sites**, the same places as the webhook `emit_*` calls: - issue/PR open: activity `opened`, with the body checked for mentions; - comment create: `comment`, with mentions; - close and reopen (including closing keywords and merges): `closed`/`reopened`/`merged`; - assign: `assigned`, notifying the assignee with reason `assign` even when they don't watch; - review submit: `review`, with the body and the review comment bodies checked for mentions; - PR head update: `pushed`; - edit of a body or comment: mentions newly added by the edit (old vs new text) only. - **Views:** - `GET /notifications`: tabs Unread and All, grouped by repo; each row shows an icon (issue or PR, by state), `owner/name #N title`, reason, last activity, relative time and a "Mark read" button. Bulk actions: "Mark all read" per repo and globally. Cursor pagination, 50 per page. - Clicking a row goes to `/notifications/{id}/open`, which marks it read and redirects 303 to the issue or PR URL. - Rows for repos the user can no longer read are hidden, and marked read lazily. - A header bell link with the unread count, for signed-in users (the `shell` in `views/mod.rs`); one cheap COUNT query. - Issue and PR sidebar: a "Notifications" box with Subscribe/Unsubscribe (POST, CSRF) and the reason ("You're receiving notifications because you were mentioned"). - Repo header: a Watch form with a `