Tahinli/forge
public0 watching
★ 0
| 1 | # Notifications (contract between workers) |
| 2 | |
| 3 | The global rules from the earlier interface docs apply: |
| 4 | - No JS and no inline styles; no unwrap in non-test code. |
| 5 | - Tests use `tempfile::TempDir`. |
| 6 | - Never put cargo target dirs or big files in /tmp. |
| 7 | |
| 8 | ## Model |
| 9 | - **One notification thread per (user, issue-or-PR).** New activity on a thread upserts its row: |
| 10 | - `unread = 1`, `updated_at = now`, `reason` = the strongest current reason, `last_actor_id`. |
| 11 | - Reason strength, highest first: `mention` > `assign` > `review_requested` > `author` > `comment` > `state_change` > `subscribed` > `watching`. |
| 12 | - **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): |
| 13 | 1. users subscribed to I (`issue_subscriptions.state = 'subscribed'`), with their stored reason; |
| 14 | 2. users watching R with level `watching`, unless they have an `ignored` subscription on I; |
| 15 | 3. users @mentioned in the new text who can read R. This auto-subscribes them with reason `mention`. |
| 16 | - **Auto-subscribe** (state subscribed, if no row exists yet): |
| 17 | - the author on create; |
| 18 | - a commenter on comment; |
| 19 | - an assignee on assign; |
| 20 | - a reviewer on review submit; |
| 21 | - anyone mentioned. |
| 22 | An explicit `ignored` row is never overwritten by auto-subscribe. |
| 23 | - **Repo watch levels:** |
| 24 | - `watching`: all activity; |
| 25 | - `participating`: the default when no row exists; subscriptions only; |
| 26 | - `ignoring`: no notifications from this repo at all, even when subscribed. **Mentions still notify.** |
| 27 | - **Activities:** |
| 28 | - `opened`, `comment`, `closed`, `reopened`, `merged`; |
| 29 | - `review` (with `state`), `assigned` (to that user: reason `assign`); |
| 30 | - `mentioned`, `pushed` (synchronize on a PR: subscribers only, not watchers). |
| 31 | |
| 32 | ## Schema (migration `0012_notifications.sql`) |
| 33 | ```sql |
| 34 | CREATE TABLE repo_watches (user_id TEXT NOT NULL REFERENCES users(id), repo_id TEXT NOT NULL REFERENCES repos(id), |
| 35 | level TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, repo_id)); |
| 36 | CREATE TABLE issue_subscriptions (user_id TEXT NOT NULL REFERENCES users(id), issue_id TEXT NOT NULL REFERENCES issues(id), |
| 37 | state TEXT NOT NULL, reason TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, issue_id)); |
| 38 | CREATE TABLE notifications (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), |
| 39 | repo_id TEXT NOT NULL REFERENCES repos(id), issue_id TEXT NOT NULL REFERENCES issues(id), |
| 40 | reason TEXT NOT NULL, last_activity TEXT NOT NULL, last_actor_id TEXT REFERENCES users(id), |
| 41 | unread INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL, UNIQUE (user_id, issue_id)); |
| 42 | CREATE INDEX notifications_inbox ON notifications(user_id, unread, updated_at); |
| 43 | ``` |
| 44 | - `Store::delete_repo` must delete these rows for the repo and its issues. |
| 45 | - `delete_user` (store/user_delete.rs) must delete the user's rows. In `notifications.last_actor_id` it reassigns the user to ghost. |
| 46 | |
| 47 | ## forge-core (`src/notifications.rs` model plus `store/notifications.rs`) |
| 48 | ```rust |
| 49 | pub enum WatchLevel { Watching, Participating, Ignoring } |
| 50 | pub enum SubState { Subscribed, Ignored } |
| 51 | pub enum Reason { Mention, Assign, ReviewRequested, Author, Comment, StateChange, Subscribed, Watching } // as_str/parse, rank() |
| 52 | pub struct Notification { pub id: Id, pub repo_id: Id, pub repo_owner: String, pub repo_name: String, pub issue_id: Id, |
| 53 | pub number: i64, pub is_pull: bool, pub title: String, pub issue_state: String, pub reason: Reason, |
| 54 | pub last_activity: String, pub last_actor_name: Option<String>, pub unread: bool, pub updated_at: String } |
| 55 | |
| 56 | set_watch(user, repo, WatchLevel); watch_level(user, repo) -> WatchLevel; watchers(repo) -> Vec<Id> // level watching |
| 57 | subscribe(user, issue, SubState, Reason); auto_subscribe(user, issue, Reason) // no-op if a row exists |
| 58 | subscription(user, issue) -> Option<(SubState, Reason)>; subscribers(issue) -> Vec<(Id, Reason)> // state subscribed |
| 59 | upsert_notification(user, repo, issue, reason, activity, actor) // keeps the stronger reason if one exists and the row is still unread |
| 60 | list_notifications(user, unread_only: bool, before: Option<&str>, limit) -> Vec<Notification> |
| 61 | unread_count(user) -> u64 |
| 62 | 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 |
| 63 | ``` |
| 64 | |
| 65 | ## forge-web |
| 66 | - `crate::notify::dispatch(state, repo, issue, actor_id, activity, text_for_mentions: Option<&str>)`: |
| 67 | - computes recipients per the rules above, then auto-subscribes and upserts; |
| 68 | - never fails the caller (it logs errors); |
| 69 | - runs **inline** (DB only, cheap). |
| 70 | - **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<String>`). Skip code spans and code blocks, and dedupe. |
| 71 | - **Call sites**, the same places as the webhook `emit_*` calls: |
| 72 | - issue/PR open: activity `opened`, with the body checked for mentions; |
| 73 | - comment create: `comment`, with mentions; |
| 74 | - close and reopen (including closing keywords and merges): `closed`/`reopened`/`merged`; |
| 75 | - assign: `assigned`, notifying the assignee with reason `assign` even when they don't watch; |
| 76 | - review submit: `review`, with the body and the review comment bodies checked for mentions; |
| 77 | - PR head update: `pushed`; |
| 78 | - edit of a body or comment: mentions newly added by the edit (old vs new text) only. |
| 79 | - **Views:** |
| 80 | - `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. |
| 81 | - Clicking a row goes to `/notifications/{id}/open`, which marks it read and redirects 303 to the issue or PR URL. |
| 82 | - Rows for repos the user can no longer read are hidden, and marked read lazily. |
| 83 | - A header bell link with the unread count, for signed-in users (the `shell` in `views/mod.rs`); one cheap COUNT query. |
| 84 | - Issue and PR sidebar: a "Notifications" box with Subscribe/Unsubscribe (POST, CSRF) and the reason ("You're receiving notifications because you were mentioned"). |
| 85 | - Repo header: a Watch form with a `<select>` (Participating / Watching / Ignoring) and a submit button (POST, CSRF); shows the watcher count. |
| 86 | - Viewing an issue or PR page calls `mark_issue_read` for the viewer. |