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 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
|