aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-11 12:40:13 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-11 12:40:13 +0200
commitedde9e441fb6b84e9d56215d6e2a8d9338b8f962 (patch)
treebe09d4cc1a64e9ccc488ca9fcc1e23b6a133a8f5 /packages/meshbay-hub
parentccb2b85051b88578c7d739ff46385e50a65591a6 (diff)
downloadmeshbay-edde9e441fb6b84e9d56215d6e2a8d9338b8f962.tar.gz
feat(hub): Phase 10.5–10.8, 10.10 — notifications, settings, search, version
- 10.5: Notification model + CRUD API (list, mark read, mark all read) Triggered on: group invite, role change, suspend/unsuspend - 10.6: SettingsPage shows role, per-group notification mute (localStorage) - 10.7: GET /v1/groups?q= search filter (ilike on name) - 10.8: NotificationFeed on home page + bell with unread badge in navbar - 10.10: GET /v1/hub/version endpoint for client update checks - 8 new tests (test_notifications.py), 155 total Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/notifications.py93
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js115
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css64
-rw-r--r--packages/meshbay-hub/tests/test_notifications.py181
10 files changed, 520 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index c3fa8c2..88e231a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -134,11 +134,17 @@ async def admin_patch_user(
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot modify your own account")
+ from meshbay_hub.api.notifications import create_notification
+
if body.role is not None:
if body.role not in ("user", "moderator", "admin"):
raise HTTPException(status_code=422, detail="role must be user, moderator, or admin")
user.role = body.role
log.info("User %s role changed to %s by %s", user.username, body.role, current_user.username)
+ await create_notification(
+ db, user.id, "role_change",
+ f"Your role has been changed to {body.role}",
+ )
if body.status is not None:
if body.status not in ("active", "suspended", "revoked"):
@@ -146,6 +152,16 @@ async def admin_patch_user(
user.status = body.status
log.info("User %s status changed to %s by %s",
user.username, body.status, current_user.username)
+ if body.status == "suspended":
+ await create_notification(
+ db, user.id, "account_suspended",
+ "Your account has been suspended",
+ )
+ elif body.status == "active":
+ await create_notification(
+ db, user.id, "account_restored",
+ "Your account has been restored",
+ )
db.add(IPLog(
user_id=current_user.id,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 6dd4275..a8a45c9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -69,16 +69,17 @@ async def group_online_nodes(
@router.get("")
async def list_public_groups(
db: AsyncSession = Depends(get_db),
+ q: str = "",
limit: int = 50,
offset: int = 0,
include_federated: bool = True,
):
- """List public groups — local and optionally federated. No auth required."""
+ """List/search public groups — local and optionally federated. No auth required."""
+ query = select(Group).where(Group.visibility == "public", Group.status == "active")
+ if q:
+ query = query.where(Group.name.ilike(f"%{q}%"))
result = await db.execute(
- select(Group)
- .where(Group.visibility == "public", Group.status == "active")
- .order_by(Group.created_at.desc())
- .limit(limit).offset(offset)
+ query.order_by(Group.created_at.desc()).limit(limit).offset(offset)
)
local = result.scalars().all()
groups = [
@@ -90,10 +91,11 @@ async def list_public_groups(
]
if include_federated:
+ fed_query = select(FederatedGroup)
+ if q:
+ fed_query = fed_query.where(FederatedGroup.name.ilike(f"%{q}%"))
fed_result = await db.execute(
- select(FederatedGroup)
- .order_by(FederatedGroup.updated_at.desc())
- .limit(limit)
+ fed_query.order_by(FederatedGroup.updated_at.desc()).limit(limit)
)
for fg in fed_result.scalars().all():
groups.append({
@@ -217,6 +219,7 @@ async def store_gek_bundle(
# Upsert GEK bundle
existing = await db.get(GEKBundle, (group_id, target.id))
+ new_member = False
if existing:
existing.pk_eph_b64 = body.pk_eph_b64
existing.nonce_b64 = body.nonce_b64
@@ -233,6 +236,15 @@ async def store_gek_bundle(
mem = await db.get(GroupMember, (group_id, target.id))
if not mem:
db.add(GroupMember(group_id=group_id, user_id=target.id))
+ new_member = True
+
+ if new_member:
+ from meshbay_hub.api.notifications import create_notification
+ await create_notification(
+ db, target.id, "group_invite",
+ f"You were added to {group.name}",
+ link=f"#/group/{group_id}",
+ )
await db.commit()
return {"status": "stored", "group_id": group_id, "username": username}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index 00aba28..6e2db9e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -24,3 +24,13 @@ async def hub_info():
async def hub_pubkey():
"""Hub Ed25519 public key PEM — cached by nodes on first contact."""
return {"pk_hub_pem": hub_public_key_pem().decode()}
+
+
+@router.get("/version")
+async def hub_version():
+ """Version check endpoint for clients to detect updates."""
+ return {
+ "hub": __version__,
+ "mnp": MNP_VERSION,
+ "mhp": MHP_VERSION,
+ }
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
new file mode 100644
index 0000000..e7d36d2
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
@@ -0,0 +1,93 @@
+"""Notification endpoints — /v1/notifications/*"""
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select, func, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import Notification, User
+
+router = APIRouter(prefix="/v1/notifications", tags=["notifications"])
+
+
+@router.get("")
+async def list_notifications(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+ limit: int = 50,
+ offset: int = 0,
+ unread_only: bool = False,
+):
+ query = select(Notification).where(Notification.user_id == current_user.id)
+ if unread_only:
+ query = query.where(Notification.read == False) # noqa: E712
+ result = await db.execute(
+ query.order_by(Notification.created_at.desc()).limit(limit).offset(offset)
+ )
+ notifs = result.scalars().all()
+
+ count_q = select(func.count()).select_from(Notification).where(
+ Notification.user_id == current_user.id, Notification.read == False # noqa: E712
+ )
+ unread = (await db.execute(count_q)).scalar() or 0
+
+ return {
+ "notifications": [
+ {
+ "id": n.id,
+ "kind": n.kind,
+ "title": n.title,
+ "detail": n.detail,
+ "link": n.link,
+ "read": n.read,
+ "created_at": n.created_at.isoformat(),
+ }
+ for n in notifs
+ ],
+ "unread_count": unread,
+ }
+
+
+@router.post("/{notification_id}/read")
+async def mark_read(
+ notification_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ notif = await db.get(Notification, notification_id)
+ if not notif or notif.user_id != current_user.id:
+ raise HTTPException(status_code=404, detail="Notification not found")
+ notif.read = True
+ await db.commit()
+ return {"status": "ok"}
+
+
+@router.post("/read-all")
+async def mark_all_read(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ await db.execute(
+ update(Notification)
+ .where(Notification.user_id == current_user.id, Notification.read == False) # noqa: E712
+ .values(read=True)
+ )
+ await db.commit()
+ return {"status": "ok"}
+
+
+async def create_notification(
+ db: AsyncSession,
+ user_id: str,
+ kind: str,
+ title: str,
+ detail: str | None = None,
+ link: str | None = None,
+) -> Notification:
+ notif = Notification(
+ user_id=user_id, kind=kind, title=title, detail=detail, link=link,
+ )
+ db.add(notif)
+ await db.flush()
+ return notif
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index d7f95cf..7011bf0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -34,6 +34,7 @@ from meshbay_hub.api.health import router as health_router
from meshbay_hub.api.relay import router as relay_router
from meshbay_hub.api.signaling import router as signaling_router
from meshbay_hub.api.admin import router as admin_router
+from meshbay_hub.api.notifications import router as notifications_router
from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR
from meshbay_hub.api.middleware import limiter
@@ -117,6 +118,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(relay_router)
app.include_router(signaling_router)
app.include_router(admin_router)
+ app.include_router(notifications_router)
app.include_router(webapp_router)
from starlette.staticfiles import StaticFiles
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index d06bcda..c420661 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -195,6 +195,26 @@ class SwarmSource(Base):
__table_args__ = (Index("ix_swarm_hash", "content_hash"),)
+class Notification(Base):
+ __tablename__ = "notifications"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
+ kind: Mapped[str] = mapped_column(String(32), nullable=False)
+ title: Mapped[str] = mapped_column(String(256), nullable=False)
+ detail: Mapped[str | None] = mapped_column(String(512))
+ link: Mapped[str | None] = mapped_column(String(256))
+ read: Mapped[bool] = mapped_column(Boolean, default=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+
+ user: Mapped["User"] = relationship()
+
+ __table_args__ = (
+ Index("ix_notifications_user", "user_id"),
+ Index("ix_notifications_created", "created_at"),
+ )
+
+
class ContentReport(Base):
"""
Report of a public content hash for moderation.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index d36a0a1..4ef334b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -85,7 +85,7 @@ function useAuth() { return useContext(AuthContext); }
// ── Nav ──────────────────────────────────────────────────────────────────────
-function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) {
+function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle, unreadCount }) {
return html`
<nav class="nav">
<div class="nav-left">
@@ -96,6 +96,11 @@ function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) {
<a class="nav-brand" href="#/">MeshBay</a>
</div>
<div class="nav-right">
+ ${user && html`
+ <a class="nav-notif" href="#/" title=${t('notif.title')}>
+ ${'\u{1F514}'}${unreadCount > 0 && html`<span class="notif-badge">${unreadCount}</span>`}
+ </a>
+ `}
<button class="nav-theme" onClick=${onThemeToggle}
aria-label="${t('nav.toggle_menu')}" title=${theme === 'dark' ? t('nav.light_mode') : t('nav.dark_mode')}>
${theme === 'dark' ? '☀' : '☾'}
@@ -273,11 +278,32 @@ function RegisterPage() {
// ── Home Page ────────────────────────────────────────────────────────────────
-function HomePage({ groups }) {
+function NotificationFeed({ notifications, onMarkRead }) {
+ if (!notifications.length) return null;
+ return html`
+ <div class="notif-feed">
+ <h3>${t('notif.title')}</h3>
+ ${notifications.map(n => html`
+ <div key=${n.id} class="notif-item ${n.read ? '' : 'notif-unread'}"
+ onClick=${() => {
+ if (!n.read) onMarkRead(n.id);
+ if (n.link) navigate(n.link);
+ }}>
+ <span class="notif-kind">${n.kind}</span>
+ <span class="notif-text">${n.title}</span>
+ <span class="notif-time">${new Date(n.created_at).toLocaleDateString()}</span>
+ </div>
+ `)}
+ </div>
+ `;
+}
+
+function HomePage({ groups, notifications, onMarkRead }) {
if (groups.length === 0) {
return html`
<div>
<h2>${t('home.welcome')}</h2>
+ <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} />
<p class="page-message">
${t('home.no_groups')}
${' '}${t('home.browse_prefix')}<a href="#/explore">${t('home.browse_link')}</a>${t('home.browse_suffix')}
@@ -289,6 +315,7 @@ function HomePage({ groups }) {
return html`
<div>
<h2>${t('home.my_groups')}</h2>
+ <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} />
<div class="group-grid">
${groups.map(g => html`
<a key=${g.id} class="group-card" href="#/group/${g.id}">
@@ -309,17 +336,32 @@ function HomePage({ groups }) {
function ExplorePage({ token }) {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
+ const [search, setSearch] = useState('');
- useEffect(() => {
- hubFetch('/v1/groups', { token })
+ const doSearch = useCallback((q) => {
+ setLoading(true);
+ const url = q ? `/v1/groups?q=${encodeURIComponent(q)}` : '/v1/groups';
+ hubFetch(url, { token })
.then(data => setGroups(data.groups || []))
.catch(() => {})
.finally(() => setLoading(false));
}, [token]);
+ useEffect(() => { doSearch(''); }, [token]);
+
+ const onSearch = useCallback((e) => {
+ const q = e.target.value;
+ setSearch(q);
+ doSearch(q);
+ }, [doSearch]);
+
return html`
<div>
<h2>${t('explore.title')}</h2>
+ <div class="file-toolbar" style="margin-bottom:16px">
+ <input type="text" class="admin-search" placeholder="${t('explore.search')}"
+ value=${search} onInput=${onSearch} />
+ </div>
${loading
? html`<p class="page-message">${t('explore.loading')}</p>`
: groups.length === 0
@@ -938,8 +980,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
const THEME_OPTIONS = ['light', 'dark', 'system'];
-function SettingsPage({ user, theme, onThemeChange }) {
+function SettingsPage({ user, theme, onThemeChange, groups }) {
const [locale, setLoc] = useState(getLocale);
+ const [muted, setMuted] = useState(() => {
+ try { return JSON.parse(localStorage.getItem('mb_muted') || '{}'); }
+ catch { return {}; }
+ });
const onLocaleChange = useCallback((e) => {
const code = e.target.value;
@@ -952,6 +998,14 @@ function SettingsPage({ user, theme, onThemeChange }) {
onThemeChange(e.target.value);
}, [onThemeChange]);
+ const toggleMute = useCallback((gid) => {
+ setMuted(prev => {
+ const next = { ...prev, [gid]: !prev[gid] };
+ localStorage.setItem('mb_muted', JSON.stringify(next));
+ return next;
+ });
+ }, []);
+
return html`
<div>
<h2>${t('settings.title')}</h2>
@@ -962,6 +1016,10 @@ function SettingsPage({ user, theme, onThemeChange }) {
<span class="settings-label">${t('settings.username')}</span>
<span class="settings-value">${user.username}</span>
</div>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.role')}</span>
+ <span class="settings-value">${user.role || 'user'}</span>
+ </div>
</div>
<div class="settings-section">
@@ -984,6 +1042,22 @@ function SettingsPage({ user, theme, onThemeChange }) {
</div>
</div>
+ ${groups.length > 0 && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.groups')}</h3>
+ ${groups.map(g => html`
+ <div class="settings-row" key=${g.id}>
+ <span class="settings-label">${g.name}</span>
+ <label class="settings-value" style="cursor:pointer">
+ <input type="checkbox" checked=${!muted[g.id]}
+ onChange=${() => toggleMute(g.id)} />
+ ${' '}${t('settings.notifications')}
+ </label>
+ </div>
+ `)}
+ </div>
+ `}
+
<div class="settings-section">
<h3 class="settings-heading">${t('settings.about')}</h3>
<div class="settings-row">
@@ -1339,6 +1413,8 @@ function App() {
const [user, setUser] = useState(loadAuth);
const [groups, setGroups] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
+ const [notifications, setNotifications] = useState([]);
+ const [unreadCount, setUnreadCount] = useState(0);
const resolved = resolveTheme(theme);
@@ -1347,13 +1423,31 @@ function App() {
localStorage.setItem(THEME_KEY, theme);
}, [theme, resolved]);
+ const fetchNotifications = useCallback(() => {
+ if (!user) return;
+ hubFetch('/v1/notifications?limit=20', { token: user.token })
+ .then(data => {
+ setNotifications(data.notifications || []);
+ setUnreadCount(data.unread_count || 0);
+ })
+ .catch(() => {});
+ }, [user]);
+
useEffect(() => {
- if (!user) { setGroups([]); return; }
+ if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); return; }
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => setGroups([]));
+ fetchNotifications();
}, [user]);
+ const markRead = useCallback((id) => {
+ if (!user) return;
+ hubFetch(`/v1/notifications/${id}/read`, { method: 'POST', token: user.token })
+ .then(() => fetchNotifications())
+ .catch(() => {});
+ }, [user, fetchNotifications]);
+
useEffect(() => { setMenuOpen(false); }, [route]);
const toggleTheme = useCallback(() => {
@@ -1408,12 +1502,12 @@ function App() {
} else if (route === '/admin') {
page = (user.role === 'moderator' || user.role === 'admin')
? html`<${AdminPage} token=${user.token} />`
- : html`<${HomePage} groups=${groups} />`;
+ : html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`;
} else if (route === '/settings') {
page = html`<${SettingsPage} user=${user} theme=${theme}
- onThemeChange=${setTheme} />`;
+ onThemeChange=${setTheme} groups=${groups} />`;
} else {
- page = html`<${HomePage} groups=${groups} />`;
+ page = html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`;
}
return html`
@@ -1423,7 +1517,8 @@ function App() {
theme=${resolved}
onThemeToggle=${toggleTheme}
onLogout=${authCtx.logout}
- onMenuToggle=${() => setMenuOpen(o => !o)} />
+ onMenuToggle=${() => setMenuOpen(o => !o)}
+ unreadCount=${unreadCount} />
<div class="layout">
${user && html`<${Sidebar}
groups=${groups}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index cb5133e..11543fd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -60,6 +60,7 @@ const en = {
// Explore
'explore.title': 'Public Groups',
+ 'explore.search': 'Search groups...',
'explore.loading': 'Loading...',
'explore.empty': 'No public groups available.',
@@ -114,6 +115,9 @@ const en = {
'settings.about': 'About',
'settings.version': 'Version',
'settings.protocol': 'Protocol',
+ 'settings.role': 'Role',
+ 'settings.groups': 'Group notifications',
+ 'settings.notifications': 'Notifications',
// Sidebar
'sidebar.settings': 'Settings',
@@ -177,6 +181,11 @@ const en = {
'admin.reason_placeholder': 'Reason',
'admin.btn_block': 'Block',
'admin.btn_unblock': 'Unblock',
+
+ // Notifications
+ 'notif.title': 'Notifications',
+ 'notif.empty': 'No notifications',
+ 'notif.mark_all_read': 'Mark all read',
};
// ── Locale registry ─────────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index b1b1a7e..3aea648 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1003,3 +1003,67 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.th-type, .td-type { display: none; }
.th-date, .td-date { display: none; }
}
+
+/* ── Notification bell ───────────────────────────────────────────────────── */
+
+.nav-notif {
+ position: relative;
+ color: var(--text);
+ text-decoration: none;
+ font-size: 1.1rem;
+ margin-right: 4px;
+}
+.notif-badge {
+ position: absolute;
+ top: -6px;
+ right: -8px;
+ background: var(--danger);
+ color: #fff;
+ font-size: 0.65rem;
+ padding: 1px 5px;
+ border-radius: 10px;
+ font-weight: 700;
+}
+
+/* ── Notification feed ───────────────────────────────────────────────────── */
+
+.notif-feed {
+ margin-bottom: 24px;
+}
+.notif-feed h3 {
+ margin: 0 0 8px;
+ font-size: 1rem;
+}
+.notif-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-radius: var(--radius);
+ background: var(--bg-surface);
+ margin-bottom: 4px;
+ cursor: pointer;
+ border: 1px solid var(--border);
+}
+.notif-item:hover {
+ background: var(--bg-raised);
+}
+.notif-unread {
+ border-left: 3px solid var(--primary);
+ font-weight: 600;
+}
+.notif-kind {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+.notif-text {
+ flex: 1;
+ font-size: 0.9rem;
+}
+.notif-time {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
diff --git a/packages/meshbay-hub/tests/test_notifications.py b/packages/meshbay-hub/tests/test_notifications.py
new file mode 100644
index 0000000..8da589a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_notifications.py
@@ -0,0 +1,181 @@
+"""Integration tests for the notification system."""
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+
+from meshbay_common.crypto import pk_to_b64
+from meshbay_hub.api.deps import set_admin_usernames
+
+
+def _gen_user_keys():
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = X25519PrivateKey.generate()
+ return pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key())
+
+
+async def _register(client, username, email="test@x.com", password="testpass99"):
+ pk_ed, pk_x = _gen_user_keys()
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": email, "password": password,
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x,
+ })
+ assert r.status_code == 201
+ return r.json()["user_id"]
+
+
+async def _login(client, username, password="testpass99"):
+ r = await client.post("/v1/users/login", json={
+ "username": username, "password": password,
+ })
+ assert r.status_code == 200
+ return r.json()["access_token"]
+
+
+async def _setup_admin(client, admin_name="admin"):
+ user_id = await _register(client, admin_name, email=f"{admin_name}@x.com")
+ set_admin_usernames([admin_name])
+ token = await _login(client, admin_name)
+ return user_id, token
+
+
+@pytest.mark.asyncio
+async def test_notifications_empty(client):
+ await _register(client, "alice", email="a@x.com")
+ token = await _login(client, "alice")
+ r = await client.get("/v1/notifications", headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ data = r.json()
+ assert data["notifications"] == []
+ assert data["unread_count"] == 0
+
+
+@pytest.mark.asyncio
+async def test_notification_on_role_change(client):
+ _, admin_token = await _setup_admin(client)
+ uid = await _register(client, "alice", email="a@x.com")
+ alice_token = await _login(client, "alice")
+
+ await client.patch(f"/v1/admin/users/{uid}",
+ json={"role": "moderator"},
+ headers={"Authorization": f"Bearer {admin_token}"})
+
+ r = await client.get("/v1/notifications",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ assert r.status_code == 200
+ data = r.json()
+ assert data["unread_count"] == 1
+ assert data["notifications"][0]["kind"] == "role_change"
+ assert "moderator" in data["notifications"][0]["title"]
+
+
+@pytest.mark.asyncio
+async def test_notification_on_suspend(client):
+ _, admin_token = await _setup_admin(client)
+ uid = await _register(client, "bob", email="b@x.com")
+ bob_token = await _login(client, "bob")
+
+ await client.patch(f"/v1/admin/users/{uid}",
+ json={"status": "suspended"},
+ headers={"Authorization": f"Bearer {admin_token}"})
+
+ await client.patch(f"/v1/admin/users/{uid}",
+ json={"status": "active"},
+ headers={"Authorization": f"Bearer {admin_token}"})
+
+ r = await client.get("/v1/notifications",
+ headers={"Authorization": f"Bearer {bob_token}"})
+ assert r.status_code == 200
+ kinds = [n["kind"] for n in r.json()["notifications"]]
+ assert "account_suspended" in kinds
+ assert "account_restored" in kinds
+
+
+@pytest.mark.asyncio
+async def test_mark_notification_read(client):
+ _, admin_token = await _setup_admin(client)
+ uid = await _register(client, "alice", email="a@x.com")
+ alice_token = await _login(client, "alice")
+
+ await client.patch(f"/v1/admin/users/{uid}",
+ json={"role": "moderator"},
+ headers={"Authorization": f"Bearer {admin_token}"})
+
+ r = await client.get("/v1/notifications",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ nid = r.json()["notifications"][0]["id"]
+
+ r = await client.post(f"/v1/notifications/{nid}/read",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ assert r.status_code == 200
+
+ r = await client.get("/v1/notifications",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ assert r.json()["unread_count"] == 0
+ assert r.json()["notifications"][0]["read"] is True
+
+
+@pytest.mark.asyncio
+async def test_mark_all_read(client):
+ _, admin_token = await _setup_admin(client)
+ uid = await _register(client, "alice", email="a@x.com")
+ alice_token = await _login(client, "alice")
+
+ await client.patch(f"/v1/admin/users/{uid}",
+ json={"role": "moderator"},
+ headers={"Authorization": f"Bearer {admin_token}"})
+ await client.patch(f"/v1/admin/users/{uid}",
+ json={"role": "admin"},
+ headers={"Authorization": f"Bearer {admin_token}"})
+
+ r = await client.get("/v1/notifications",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ assert r.json()["unread_count"] == 2
+
+ r = await client.post("/v1/notifications/read-all",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ assert r.status_code == 200
+
+ r = await client.get("/v1/notifications",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ assert r.json()["unread_count"] == 0
+
+
+@pytest.mark.asyncio
+async def test_notification_unread_filter(client):
+ _, admin_token = await _setup_admin(client)
+ uid = await _register(client, "alice", email="a@x.com")
+ alice_token = await _login(client, "alice")
+
+ await client.patch(f"/v1/admin/users/{uid}",
+ json={"role": "moderator"},
+ headers={"Authorization": f"Bearer {admin_token}"})
+ await client.patch(f"/v1/admin/users/{uid}",
+ json={"role": "admin"},
+ headers={"Authorization": f"Bearer {admin_token}"})
+
+ r = await client.get("/v1/notifications",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ nid = r.json()["notifications"][0]["id"]
+ await client.post(f"/v1/notifications/{nid}/read",
+ headers={"Authorization": f"Bearer {alice_token}"})
+
+ r = await client.get("/v1/notifications?unread_only=true",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ assert len(r.json()["notifications"]) == 1
+
+
+@pytest.mark.asyncio
+async def test_notification_requires_auth(client):
+ r = await client.get("/v1/notifications")
+ assert r.status_code in (401, 403, 422)
+
+
+@pytest.mark.asyncio
+async def test_hub_version_endpoint(client):
+ r = await client.get("/v1/hub/version")
+ assert r.status_code == 200
+ data = r.json()
+ assert "hub" in data
+ assert "mnp" in data
+ assert "mhp" in data