aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
blob: b5783ab6af88847d30bec879c570b086e38b6130 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
"""
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 fastapi import APIRouter, Depends, HTTPException, Query
from datetime import datetime, timezone

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(timezone.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