-
-
Notifications
You must be signed in to change notification settings - Fork 38
notifications: broadcast rows share one read/archived flag across every user — one member's mark-read/archive changes what all members see #2829
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| ### Fixed | ||
|
|
||
| - Broadcast notifications now have per-user read/archived state to prevent cross-user state leaks. Previously, when one user marked a broadcast notification as read or archived it, it affected all users' inboxes. Now each user's read/archived status is tracked independently in the `notification_user_state` table, preserving individual inbox states while maintaining the shared broadcast nature of the notification. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -33,6 +33,13 @@ | |||||||||||||
| event_type TEXT PRIMARY KEY, | ||||||||||||||
| muted INTEGER NOT NULL DEFAULT 0 | ||||||||||||||
| ); | ||||||||||||||
| CREATE TABLE IF NOT EXISTS notification_user_state ( | ||||||||||||||
| notification_id INTEGER NOT NULL, | ||||||||||||||
| user_id TEXT NOT NULL, | ||||||||||||||
| read_at INTEGER, | ||||||||||||||
| archived_at INTEGER, | ||||||||||||||
| PRIMARY KEY (notification_id, user_id) | ||||||||||||||
| ); | ||||||||||||||
| """ | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
|
|
@@ -147,6 +154,24 @@ async def _post_init(self) -> None: | |||||||||||||
| "CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id)" | ||||||||||||||
| ) | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| # Ensure the notification_user_state table exists for per-user broadcast state. | ||||||||||||||
| await self._db.execute( | ||||||||||||||
| """ | ||||||||||||||
| CREATE TABLE IF NOT EXISTS notification_user_state ( | ||||||||||||||
| notification_id INTEGER NOT NULL, | ||||||||||||||
| user_id TEXT NOT NULL, | ||||||||||||||
| read_at INTEGER, | ||||||||||||||
| archived_at INTEGER, | ||||||||||||||
| PRIMARY KEY (notification_id, user_id) | ||||||||||||||
| ); | ||||||||||||||
| """ | ||||||||||||||
| ) | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| # Create an index on notification_id for efficient lookups. | ||||||||||||||
| await self._db.execute( | ||||||||||||||
| "CREATE INDEX IF NOT EXISTS idx_notif_state_notif ON notification_user_state(notification_id)" | ||||||||||||||
| ) | ||||||||||||||
| await self._db.commit() | ||||||||||||||
|
|
||||||||||||||
| async def add( | ||||||||||||||
| self, | ||||||||||||||
|
|
@@ -217,80 +242,155 @@ async def list( | |||||||||||||
| unread_only: bool = False, | ||||||||||||||
| user_id: str | None = None, | ||||||||||||||
| ) -> list[dict]: | ||||||||||||||
| # Active feed: archived (dismissed) notifications are excluded. | ||||||||||||||
| conds = ["archived = 0"] | ||||||||||||||
| if user_id is not None: | ||||||||||||||
| conds.append("(user_id IS NULL OR user_id = ?)") | ||||||||||||||
| if unread_only: | ||||||||||||||
| conds.append("read = 0") | ||||||||||||||
| params: tuple = (user_id, limit) if user_id is not None else (limit,) | ||||||||||||||
| sql = ( | ||||||||||||||
| "SELECT id, timestamp, level, title, message, read, source, data, user_id FROM notifications" | ||||||||||||||
| f" WHERE {' AND '.join(conds)} ORDER BY timestamp DESC LIMIT ?" | ||||||||||||||
| ) | ||||||||||||||
| async with self._db.execute(sql, params) as cursor: | ||||||||||||||
| rows = await cursor.fetchall() | ||||||||||||||
| return [_serialize_row(r) for r in rows] | ||||||||||||||
| # Per-user notifications: include both own notifications and broadcasts | ||||||||||||||
| sql = ( | ||||||||||||||
| "SELECT n.id, n.timestamp, n.level, n.title, n.message, " | ||||||||||||||
| "COALESCE(nus.read_at, n.read) as read, n.source, n.data, n.user_id " | ||||||||||||||
| "FROM notifications n " | ||||||||||||||
| "LEFT JOIN notification_user_state nus ON n.id = nus.notification_id AND nus.user_id = ? " | ||||||||||||||
| f"WHERE (n.user_id IS NULL OR n.user_id = ?) AND n.archived = 0" | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Remove the unused f-string prefixes. Ruff reports F541 for each of these static SQL fragments.
🧰 Tools🪛 Ruff (0.16.3)[error] 252-252: f-string without any placeholders Remove extraneous (F541) 📍 Affects 1 file
🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||||||||||||||
| + (" AND COALESCE(nus.read_at, n.read) = 0" if unread_only else "") + | ||||||||||||||
|
Comment on lines
+252
to
+253
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Exclude broadcasts archived by the current user from the active list.
Add a 🧰 Tools🪛 Ruff (0.16.3)[error] 252-252: f-string without any placeholders Remove extraneous (F541) 🤖 Prompt for AI Agents |
||||||||||||||
| " ORDER BY n.timestamp DESC LIMIT ?" | ||||||||||||||
| ) | ||||||||||||||
| params: tuple = (user_id, user_id, limit) | ||||||||||||||
| async with self._db.execute(sql, params) as cursor: | ||||||||||||||
| rows = await cursor.fetchall() | ||||||||||||||
| return [_serialize_row(r) for r in rows] | ||||||||||||||
| else: | ||||||||||||||
| # Internal/system caller: unfiltered by user | ||||||||||||||
| conds = ["archived = 0"] | ||||||||||||||
| if unread_only: | ||||||||||||||
| conds.append("read = 0") | ||||||||||||||
| sql = ( | ||||||||||||||
| "SELECT id, timestamp, level, title, message, read, source, data, user_id FROM notifications" | ||||||||||||||
| f" WHERE {' AND '.join(conds)} ORDER BY timestamp DESC LIMIT ?" | ||||||||||||||
| ) | ||||||||||||||
| params: tuple = (limit,) | ||||||||||||||
| async with self._db.execute(sql, params) as cursor: | ||||||||||||||
| rows = await cursor.fetchall() | ||||||||||||||
| return [_serialize_row(r) for r in rows] | ||||||||||||||
|
|
||||||||||||||
| async def list_archived( | ||||||||||||||
| self, | ||||||||||||||
| limit: int = 50, | ||||||||||||||
| user_id: str | None = None, | ||||||||||||||
| ) -> list[dict]: | ||||||||||||||
| # History view: the dismissed notifications, newest first. Nothing is | ||||||||||||||
| # deleted, so this is the durable record (#62 / append-only #103). | ||||||||||||||
| conds = ["archived = 1"] | ||||||||||||||
| if user_id is not None: | ||||||||||||||
| conds.append("(user_id IS NULL OR user_id = ?)") | ||||||||||||||
| params: tuple = (user_id, limit) if user_id is not None else (limit,) | ||||||||||||||
| async with self._db.execute( | ||||||||||||||
| "SELECT id, timestamp, level, title, message, read, source, data, user_id FROM notifications" | ||||||||||||||
| f" WHERE {' AND '.join(conds)} ORDER BY timestamp DESC LIMIT ?", | ||||||||||||||
| params, | ||||||||||||||
| ) as cursor: | ||||||||||||||
| rows = await cursor.fetchall() | ||||||||||||||
| return [_serialize_row(r) for r in rows] | ||||||||||||||
| # Per-user notifications: include both own notifications and broadcasts | ||||||||||||||
| # Archive state for broadcasts is stored in notification_user_state | ||||||||||||||
| sql = ( | ||||||||||||||
| "SELECT n.id, n.timestamp, n.level, n.title, n.message, " | ||||||||||||||
| "COALESCE(nus.read_at, n.read) as read, n.source, n.data, n.user_id " | ||||||||||||||
| "FROM notifications n " | ||||||||||||||
| "LEFT JOIN notification_user_state nus ON n.id = nus.notification_id AND nus.user_id = ? " | ||||||||||||||
| f"WHERE (n.user_id IS NULL OR n.user_id = ?) AND (n.archived = 1 OR nus.archived_at IS NOT NULL)" | ||||||||||||||
| " ORDER BY n.timestamp DESC LIMIT ?" | ||||||||||||||
| ) | ||||||||||||||
| params = (user_id, user_id, limit) | ||||||||||||||
| async with self._db.execute(sql, params) as cursor: | ||||||||||||||
| rows = await cursor.fetchall() | ||||||||||||||
| return [_serialize_row(r) for r in rows] | ||||||||||||||
| else: | ||||||||||||||
| # Internal/system caller: unfiltered by user | ||||||||||||||
| sql = ( | ||||||||||||||
| "SELECT id, timestamp, level, title, message, read, source, data, user_id FROM notifications" | ||||||||||||||
| " WHERE archived = 1 ORDER BY timestamp DESC LIMIT ?" | ||||||||||||||
| ) | ||||||||||||||
| params = (limit,) | ||||||||||||||
| async with self._db.execute(sql, params) as cursor: | ||||||||||||||
| rows = await cursor.fetchall() | ||||||||||||||
| return [_serialize_row(r) for r in rows] | ||||||||||||||
|
|
||||||||||||||
| async def unread_count(self, user_id: str | None = None) -> int: | ||||||||||||||
| conds = ["read = 0", "archived = 0"] | ||||||||||||||
| if user_id is not None: | ||||||||||||||
| conds.append("(user_id IS NULL OR user_id = ?)") | ||||||||||||||
| params: tuple = (user_id,) if user_id is not None else () | ||||||||||||||
| async with self._db.execute( | ||||||||||||||
| f"SELECT COUNT(*) FROM notifications WHERE {' AND '.join(conds)}", | ||||||||||||||
| params, | ||||||||||||||
| ) as cursor: | ||||||||||||||
| # For per-user notifications, include both own notifications and broadcasts | ||||||||||||||
| # Read state for broadcasts is stored in notification_user_state | ||||||||||||||
| sql = ( | ||||||||||||||
| "SELECT COUNT(*) FROM notifications " | ||||||||||||||
| "LEFT JOIN notification_user_state nus ON n.id = nus.notification_id AND nus.user_id = ? " | ||||||||||||||
| f"WHERE (n.user_id IS NULL OR n.user_id = ?) AND archived = 0 AND COALESCE(nus.read_at, n.read) = 0" | ||||||||||||||
|
Comment on lines
+310
to
+312
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Alias The - "SELECT COUNT(*) FROM notifications "
+ "SELECT COUNT(*) FROM notifications n "📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.16.3)[error] 312-312: f-string without any placeholders Remove extraneous (F541) 🤖 Prompt for AI Agents |
||||||||||||||
| ) | ||||||||||||||
| params = (user_id, user_id) | ||||||||||||||
| else: | ||||||||||||||
| # Internal/system caller: unfiltered by user | ||||||||||||||
| sql = ( | ||||||||||||||
| "SELECT COUNT(*) FROM notifications " | ||||||||||||||
| "WHERE read = 0 AND archived = 0" | ||||||||||||||
| ) | ||||||||||||||
| params = () | ||||||||||||||
| async with self._db.execute(sql, params) as cursor: | ||||||||||||||
| row = await cursor.fetchone() | ||||||||||||||
| return row[0] if row else 0 | ||||||||||||||
|
|
||||||||||||||
| async def mark_read(self, notif_id: int, user_id: str | None = None) -> int: | ||||||||||||||
| if user_id is not None: | ||||||||||||||
| # Per-user or broadcast notification for a specific user: | ||||||||||||||
| # Update shared columns if it's a per-user notification | ||||||||||||||
| # For broadcast notifications, update per-user state | ||||||||||||||
| ts = int(time.time()) | ||||||||||||||
| cursor = await self._db.execute( | ||||||||||||||
| "UPDATE notifications SET read = 1 WHERE id = ? AND (user_id IS NULL OR user_id = ?)", | ||||||||||||||
| (notif_id, user_id), | ||||||||||||||
| "SELECT user_id FROM notifications WHERE id = ?", (notif_id,) | ||||||||||||||
| ) | ||||||||||||||
| row = await cursor.fetchone() | ||||||||||||||
|
|
||||||||||||||
| if row and row[0] is None: | ||||||||||||||
| # This is a broadcast notification - upsert into per-user state | ||||||||||||||
| await self._db.execute( | ||||||||||||||
| "INSERT OR REPLACE INTO notification_user_state (notification_id, user_id, read_at) VALUES (?, ?, ?)", | ||||||||||||||
| (notif_id, user_id, ts), | ||||||||||||||
|
Comment on lines
+340
to
+341
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Preserve the other per-user state field during upserts.
📍 Affects 1 file
🤖 Prompt for AI Agents |
||||||||||||||
| ) | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| return 1 | ||||||||||||||
| else: | ||||||||||||||
| # This is a per-user notification - update shared columns | ||||||||||||||
| cursor = await self._db.execute( | ||||||||||||||
| "UPDATE notifications SET read = 1 WHERE id = ? AND (user_id IS NULL OR user_id = ?)", | ||||||||||||||
| (notif_id, user_id), | ||||||||||||||
| ) | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| return cursor.rowcount | ||||||||||||||
| else: | ||||||||||||||
| # Internal/system caller: unfiltered update. | ||||||||||||||
| # System/internal caller: update shared columns for broadcast notifications | ||||||||||||||
| cursor = await self._db.execute( | ||||||||||||||
| "UPDATE notifications SET read = 1 WHERE id = ?", (notif_id,) | ||||||||||||||
| ) | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| return cursor.rowcount | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| return cursor.rowcount | ||||||||||||||
|
|
||||||||||||||
| async def archive(self, notif_id: int, user_id: str | None = None) -> int: | ||||||||||||||
| # Dismiss = archive. The row stays; the History view still shows it. | ||||||||||||||
| if user_id is not None: | ||||||||||||||
| # Check if this is a broadcast notification | ||||||||||||||
| cursor = await self._db.execute( | ||||||||||||||
| "UPDATE notifications SET archived = 1 WHERE id = ? AND (user_id IS NULL OR user_id = ?)", | ||||||||||||||
| (notif_id, user_id), | ||||||||||||||
| "SELECT user_id FROM notifications WHERE id = ?", (notif_id,) | ||||||||||||||
| ) | ||||||||||||||
| row = await cursor.fetchone() | ||||||||||||||
|
|
||||||||||||||
| if row and row[0] is None: | ||||||||||||||
| # This is a broadcast notification | ||||||||||||||
| ts = int(time.time()) | ||||||||||||||
| await self._db.execute( | ||||||||||||||
| "INSERT OR REPLACE INTO notification_user_state (notification_id, user_id, archived_at) VALUES (?, ?, ?)", | ||||||||||||||
| (notif_id, user_id, ts), | ||||||||||||||
| ) | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| # Do NOT update the shared archived column for broadcasts | ||||||||||||||
| return 1 | ||||||||||||||
| else: | ||||||||||||||
| # This is a per-user notification | ||||||||||||||
| cursor = await self._db.execute( | ||||||||||||||
| "UPDATE notifications SET archived = 1 WHERE id = ? AND (user_id IS NULL OR user_id = ?)", | ||||||||||||||
| (notif_id, user_id), | ||||||||||||||
| ) | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| return cursor.rowcount | ||||||||||||||
| else: | ||||||||||||||
| # Internal/system caller: unfiltered update. | ||||||||||||||
| # Internal/system caller: unfiltered update | ||||||||||||||
| cursor = await self._db.execute( | ||||||||||||||
| "UPDATE notifications SET archived = 1 WHERE id = ?", (notif_id,) | ||||||||||||||
| ) | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| return cursor.rowcount | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| return cursor.rowcount | ||||||||||||||
|
|
||||||||||||||
| async def archive_by_source_ref(self, source: str, request_id) -> int: | ||||||||||||||
| """Archive active notifications whose JSON `data.request_id` matches. | ||||||||||||||
|
|
@@ -331,15 +431,27 @@ async def archive_by_source_ref(self, source: str, request_id) -> int: | |||||||||||||
|
|
||||||||||||||
| async def mark_all_read(self, user_id: str | None = None) -> int: | ||||||||||||||
| if user_id is not None: | ||||||||||||||
| # Mark all per-user notifications (both user-specific and broadcasts for that user) as read | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: sed -n '1,260p' tinyagentos/notifications.py
sed -n '380,470p' tinyagentos/notifications.py
sed -n '125,160p' tinyagentos/routes/notifications.pyRepository: jaylfc/taOS Length of output: 16684 🏁 Script executed: rg -n -C 8 "def mark_all_read|async def mark_all_read|notification_user_state|COALESCE\\(.*read|user_id IS NULL" tinyagentos/notifications.py tinyagentos/routes/notifications.py tests/test_notifications.pyRepository: jaylfc/taOS Length of output: 16988 Authorization Bypass (CWE-284) Reachability: External · Exploitability: Moderate Do not update shared broadcast rows in The Update 🤖 Prompt for AI Agents |
||||||||||||||
| cursor = await self._db.execute( | ||||||||||||||
| "UPDATE notifications SET read = 1 WHERE read = 0 AND (user_id IS NULL OR user_id = ?)", | ||||||||||||||
| (user_id,), | ||||||||||||||
| ) | ||||||||||||||
| await self._db.commit() | ||||||||||||||
|
|
||||||||||||||
| # Also mark any per-user state as read for this user | ||||||||||||||
| await self._db.execute( | ||||||||||||||
| "UPDATE notification_user_state SET read_at = ? WHERE user_id = ?", | ||||||||||||||
| (int(time.time()), user_id), | ||||||||||||||
| ) | ||||||||||||||
| await self._db.commit() | ||||||||||||||
|
|
||||||||||||||
| return cursor.rowcount | ||||||||||||||
| else: | ||||||||||||||
| # Internal/system caller: unfiltered update. | ||||||||||||||
| # System/internal caller: mark all notifications as read | ||||||||||||||
| # This updates the shared read column for both per-user and broadcast notifications | ||||||||||||||
| cursor = await self._db.execute("UPDATE notifications SET read = 1 WHERE read = 0") | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| return cursor.rowcount | ||||||||||||||
| await self._db.commit() | ||||||||||||||
| return cursor.rowcount | ||||||||||||||
|
|
||||||||||||||
| async def cleanup(self, max_age_days: int = 30) -> int: | ||||||||||||||
| # Age out only old UNdismissed notifications. Archived rows are the | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Rename this fragment to use the PR number prefix.
The required
<pr>prefix for this PR is2829. Rename the file tochangelog.d/2829-notification-broadcast-state.md.As per coding guidelines, “A non-test change under
tinyagentos/ordesktop/src/requires achangelog.d/<pr>-<slug>.mdfragment.”🤖 Prompt for AI Agents
Source: Coding guidelines