Monorepo for Tangled
tangled.org
1package notifications
2
3import (
4 "log/slog"
5 "net/http"
6 "strconv"
7
8 "github.com/go-chi/chi/v5"
9 "tangled.org/core/appview/db"
10 "tangled.org/core/appview/middleware"
11 "tangled.org/core/appview/models"
12 "tangled.org/core/appview/oauth"
13 "tangled.org/core/appview/pages"
14 "tangled.org/core/appview/pagination"
15 "tangled.org/core/orm"
16)
17
18type Notifications struct {
19 db *db.DB
20 oauth *oauth.OAuth
21 pages *pages.Pages
22 logger *slog.Logger
23}
24
25func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications {
26 return &Notifications{
27 db: database,
28 oauth: oauthHandler,
29 pages: pagesHandler,
30 logger: logger,
31 }
32}
33
34func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
35 r := chi.NewRouter()
36
37 r.Get("/count", n.getUnreadCount)
38
39 r.Group(func(r chi.Router) {
40 r.Use(middleware.AuthMiddleware(n.oauth))
41 r.With(middleware.Paginate).Get("/", n.notificationsPage)
42 r.Get("/preview", n.previewHandler)
43 r.Post("/{id}/read", n.markRead)
44 r.Post("/{id}/unread", n.markUnread)
45 r.Post("/read-all", n.markAllRead)
46 r.Delete("/{id}", n.deleteNotification)
47 })
48
49 return r
50}
51
52func notificationFilters(r *http.Request, userDid string) (filters []orm.Filter, readFilter, categoryFilter string) {
53 filters = []orm.Filter{orm.FilterEq("recipient_did", userDid)}
54
55 readFilter = r.URL.Query().Get("read")
56 if readFilter != "unread" {
57 readFilter = "inbox"
58 }
59 if readFilter == "unread" {
60 filters = append(filters, orm.FilterEq("read", 0))
61 }
62
63 categoryFilter = r.URL.Query().Get("category")
64 switch categoryFilter {
65 case "social":
66 filters = append(filters, orm.FilterIn("type", models.SocialNotificationTypes))
67 case "work":
68 filters = append(filters, orm.FilterIn("type", models.WorkNotificationTypes))
69 default:
70 categoryFilter = "all"
71 }
72
73 return filters, readFilter, categoryFilter
74}
75
76func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
77 l := n.logger.With("handler", "notificationsPage")
78 user := n.oauth.GetMultiAccountUser(r)
79
80 page := pagination.FromContext(r.Context())
81 filters, readFilter, categoryFilter := notificationFilters(r, user.Did)
82
83 // mobile: respects category filter
84 mobileTotal, err := db.CountNotifications(n.db, filters...)
85 if err != nil {
86 l.Error("failed to get total notifications", "err", err)
87 n.pages.Error500(w)
88 return
89 }
90 notifications, err := db.GetNotificationsWithEntities(n.db, page, filters...)
91 if err != nil {
92 l.Error("failed to get notifications", "err", err)
93 n.pages.Error500(w)
94 return
95 }
96
97 // desktop columns: category is fixed, only read filter applies
98 readFilters := []orm.Filter{orm.FilterEq("recipient_did", user.Did)}
99 if readFilter == "unread" {
100 readFilters = append(readFilters, orm.FilterEq("read", 0))
101 }
102 workTotal, err := db.CountNotifications(n.db,
103 append(readFilters, orm.FilterIn("type", models.WorkNotificationTypes))...,
104 )
105 if err != nil {
106 l.Error("failed to count work notifications", "err", err)
107 n.pages.Error500(w)
108 return
109 }
110 workNotifications, err := db.GetNotificationsWithEntities(n.db, page,
111 append(readFilters, orm.FilterIn("type", models.WorkNotificationTypes))...,
112 )
113 if err != nil {
114 l.Error("failed to get work notifications", "err", err)
115 n.pages.Error500(w)
116 return
117 }
118 socialTotal, err := db.CountNotifications(n.db,
119 append(readFilters, orm.FilterIn("type", models.SocialNotificationTypes))...,
120 )
121 if err != nil {
122 l.Error("failed to count social notifications", "err", err)
123 n.pages.Error500(w)
124 return
125 }
126 socialNotifications, err := db.GetNotificationsWithEntities(n.db, page,
127 append(readFilters, orm.FilterIn("type", models.SocialNotificationTypes))...,
128 )
129 if err != nil {
130 l.Error("failed to get social notifications", "err", err)
131 n.pages.Error500(w)
132 return
133 }
134
135 // shared pagination total: max of all relevant counts
136 total := int(max(socialTotal, max(workTotal, mobileTotal)))
137
138 unreadBase := []orm.Filter{
139 orm.FilterEq("recipient_did", user.Did),
140 orm.FilterEq("read", 0),
141 }
142 workUnreadCount, err := db.CountNotifications(n.db,
143 append(unreadBase, orm.FilterIn("type", models.WorkNotificationTypes))...,
144 )
145 if err != nil {
146 l.Error("failed to count work unread", "err", err)
147 }
148 socialUnreadCount, err := db.CountNotifications(n.db,
149 append(unreadBase, orm.FilterIn("type", models.SocialNotificationTypes))...,
150 )
151 if err != nil {
152 l.Error("failed to count social unread", "err", err)
153 }
154
155 focusCount, err := db.CountFocusNotifs(n.db, user.Did)
156 if err != nil {
157 l.Error("failed to count focus notifs", "err", err)
158 }
159
160 err = n.pages.Notifications(w, pages.NotificationsParams{
161 BaseParams: pages.BaseParamsFromContext(r.Context()),
162 MobileGroups: pages.GroupNotificationsByDate(notifications),
163 WorkGroups: pages.GroupNotificationsByDate(workNotifications),
164 SocialGroups: pages.GroupNotificationsByDate(socialNotifications),
165 WorkUnreadCount: workUnreadCount,
166 SocialUnreadCount: socialUnreadCount,
167 Page: page,
168 Total: total,
169 ReadFilter: readFilter,
170 CategoryFilter: categoryFilter,
171 CanFocus: focusCount > 1,
172 })
173 if err != nil {
174 l.Error("failed to render page", "err", err)
175 }
176}
177
178func (n *Notifications) previewHandler(w http.ResponseWriter, r *http.Request) {
179 l := n.logger.With("handler", "previewHandler")
180 user := n.oauth.GetMultiAccountUser(r)
181
182 filters, readFilter, categoryFilter := notificationFilters(r, user.Did)
183
184 notifications, err := db.GetNotificationsWithEntities(
185 n.db,
186 pagination.Page{Limit: 5, Offset: 0},
187 filters...,
188 )
189 if err != nil {
190 l.Error("failed to get notifications", "err", err)
191 n.pages.Error500(w)
192 return
193 }
194
195 focusCount, err := db.CountFocusNotifs(n.db, user.Did)
196 if err != nil {
197 l.Error("failed to count focus notifs", "err", err)
198 }
199
200 err = n.pages.NotificationPreview(w, pages.NotificationPreviewParams{
201 BaseParams: pages.BaseParamsFromContext(r.Context()),
202 Notifications: notifications,
203 ReadFilter: readFilter,
204 CategoryFilter: categoryFilter,
205 CanFocus: focusCount > 1,
206 })
207 if err != nil {
208 l.Error("failed to render notification preview", "err", err)
209 }
210}
211
212func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
213 user := n.oauth.GetMultiAccountUser(r)
214 if user == nil {
215 http.Error(w, "Forbidden", http.StatusUnauthorized)
216 return
217 }
218
219 count, err := db.CountNotifications(
220 n.db,
221 orm.FilterEq("recipient_did", user.Did),
222 orm.FilterEq("read", 0),
223 )
224 if err != nil {
225 http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
226 return
227 }
228
229 params := pages.NotificationCountParams{
230 Count: count,
231 }
232 err = n.pages.NotificationCount(w, params)
233 if err != nil {
234 http.Error(w, "Failed to render count", http.StatusInternalServerError)
235 return
236 }
237}
238
239func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) {
240 n.toggleRead(w, r, true)
241}
242
243func (n *Notifications) markUnread(w http.ResponseWriter, r *http.Request) {
244 n.toggleRead(w, r, false)
245}
246
247func (n *Notifications) toggleRead(w http.ResponseWriter, r *http.Request, read bool) {
248 l := n.logger.With("handler", "toggleRead")
249 userDid := n.oauth.GetDid(r)
250
251 idStr := chi.URLParam(r, "id")
252 notificationID, err := strconv.ParseInt(idStr, 10, 64)
253 if err != nil {
254 http.Error(w, "Invalid notification ID", http.StatusBadRequest)
255 return
256 }
257
258 if read {
259 err = db.MarkNotificationRead(n.db, notificationID, userDid)
260 } else {
261 err = db.MarkNotificationUnread(n.db, notificationID, userDid)
262 }
263 if err != nil {
264 http.Error(w, "Failed to update notification", http.StatusInternalServerError)
265 return
266 }
267
268 // if called via HTMX (has HX-Request header), return the updated item fragment
269 if r.Header.Get("HX-Request") == "true" {
270 notif, err := db.GetNotificationWithEntity(n.db, notificationID, userDid)
271 if err != nil {
272 l.Error("failed to fetch notification after toggle", "err", err)
273 http.Error(w, "Failed to fetch notification", http.StatusInternalServerError)
274 return
275 }
276 if err := n.pages.NotificationItem(w, notif); err != nil {
277 l.Error("failed to render notification item", "err", err)
278 }
279 return
280 }
281
282 w.WriteHeader(http.StatusNoContent)
283}
284
285func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
286 userDid := n.oauth.GetDid(r)
287
288 err := db.MarkAllNotificationsRead(n.db, userDid)
289 if err != nil {
290 http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
291 return
292 }
293
294 w.WriteHeader(http.StatusOK)
295}
296
297func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) {
298 userDid := n.oauth.GetDid(r)
299
300 idStr := chi.URLParam(r, "id")
301 notificationID, err := strconv.ParseInt(idStr, 10, 64)
302 if err != nil {
303 http.Error(w, "Invalid notification ID", http.StatusBadRequest)
304 return
305 }
306
307 err = db.DeleteNotification(n.db, notificationID, userDid)
308 if err != nil {
309 http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
310 return
311 }
312
313 w.WriteHeader(http.StatusOK)
314}