""" Notification endpoints — /v1/notifications/* **Dismissing one deletes it.** These are signals, not a record: the group is still there, the message is still in the chat, the invitation is still an invitation, so nothing is lost by dropping the row — which is what `purge_notifications` below has always said, now applied to one at a time. That resolves a disagreement between two halves that were each defensible alone. The interface treats a click as "this is gone" and removes the entry; the hub marked it read and kept it; and the next launch listed read entries too, so everything dismissed came back. Filtering the list to unread fixed what the user saw and left the rows accumulating for nothing, invisible for ever — which is the state this replaces. `Notification.read` is therefore **vestigial**: nothing stored can be read, because reading it deletes it. It stays because dropping a column is a migration for no gain, and `unread_only` stays because it is what an older interface asks for and it still answers correctly — every row is unread. """ from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import delete, func, select 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 GroupMember, 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), # Bounded like every list in admin.py. These two were not, so one caller # could ask for the whole table in one query — and a negative limit is a # 500 on PostgreSQL rather than an empty page. limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=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, "group_id": n.group_id, "read": n.read, "created_at": n.created_at.isoformat(), } for n in notifs ], "unread_count": unread, } @router.delete("/{notification_id}") @router.post("/{notification_id}/read") async def dismiss( notification_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ Dismiss one. The row goes. Two paths to the same handler. `DELETE /{id}` says what happens and is what the interface calls; `POST /{id}/read` is what every already-installed client calls, and it has to keep working — the SPA ships inside the desktop package, so a hub is always talking to some interface older than itself. Giving the old path the new behaviour means those clients stop accumulating rows too, rather than only the ones that have been updated. """ 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") await db.delete(notif) await db.commit() return {"status": "ok"} @router.delete("") async def purge_notifications( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ Throw them all away. These are signals, not a record: the group is still there, the message is still in the chat, the invitation is still an invitation. Nothing is lost by clearing the list, so it clears rather than marking a hundred rows read — the reasoning the whole module now follows. """ result = await db.execute( delete(Notification).where(Notification.user_id == current_user.id)) await db.commit() return {"status": "ok", "removed": result.rowcount} @router.post("/read-all") async def dismiss_all( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ Dismiss every one — the same thing as `DELETE ""`, under the name an older client knows it by. Marking them read instead would put back exactly what this change removes: rows the list can never show again. Nothing in this repository calls it, but an endpoint that is reachable is an endpoint that can be called, and it should not be the one route that still hoards. """ result = await db.execute( delete(Notification).where(Notification.user_id == current_user.id)) await db.commit() return {"status": "ok", "removed": result.rowcount} async def create_notification( db: AsyncSession, user_id: str, kind: str, title: str, detail: str | None = None, link: str | None = None, group_id: str | None = None, aggregate: bool = False, ) -> Notification | None: """ Create a notification, or refresh the one already standing for this group. `aggregate` is for anything that repeats — chat, above all. One row per (person, kind, group) whose date moves and whose read flag clears, so a busy conversation is a single line saying when it last spoke rather than forty saying that it spoke. Returns None when the person muted this group: the point of muting is that nothing is created, not that something is created and hidden. """ if group_id is not None: muted = await db.execute( select(GroupMember.muted).where( GroupMember.group_id == group_id, GroupMember.user_id == user_id, ) ) if muted.scalar() is True: return None if aggregate and group_id is not None: existing = (await db.execute( select(Notification).where( Notification.user_id == user_id, Notification.kind == kind, Notification.group_id == group_id, ).order_by(Notification.created_at.desc()).limit(1) )).scalar_one_or_none() if existing is not None: existing.title = title existing.detail = detail existing.link = link existing.read = False existing.created_at = datetime.now(UTC) await db.flush() return existing notif = Notification( user_id=user_id, kind=kind, title=title, detail=detail, link=link, group_id=group_id, ) db.add(notif) await db.flush() return notif