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 err = n.pages.Notifications(w, pages.NotificationsParams{
156 BaseParams: pages.BaseParamsFromContext(r.Context()),
157 MobileGroups: pages.GroupNotificationsByDate(notifications),
158 WorkGroups: pages.GroupNotificationsByDate(workNotifications),
159 SocialGroups: pages.GroupNotificationsByDate(socialNotifications),
160 WorkUnreadCount: workUnreadCount,
161 SocialUnreadCount: socialUnreadCount,
162 Page: page,
163 Total: total,
164 ReadFilter: readFilter,
165 CategoryFilter: categoryFilter,
166 })
167 if err != nil {
168 l.Error("failed to render page", "err", err)
169 }
170}
171
172func (n *Notifications) previewHandler(w http.ResponseWriter, r *http.Request) {
173 l := n.logger.With("handler", "previewHandler")
174 user := n.oauth.GetMultiAccountUser(r)
175
176 filters, readFilter, categoryFilter := notificationFilters(r, user.Did)
177
178 notifications, err := db.GetNotificationsWithEntities(
179 n.db,
180 pagination.Page{Limit: 5, Offset: 0},
181 filters...,
182 )
183 if err != nil {
184 l.Error("failed to get notifications", "err", err)
185 n.pages.Error500(w)
186 return
187 }
188
189 err = n.pages.NotificationPreview(w, pages.NotificationPreviewParams{
190 BaseParams: pages.BaseParamsFromContext(r.Context()),
191 Notifications: notifications,
192 ReadFilter: readFilter,
193 CategoryFilter: categoryFilter,
194 })
195 if err != nil {
196 l.Error("failed to render notification preview", "err", err)
197 }
198}
199
200func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
201 user := n.oauth.GetMultiAccountUser(r)
202 if user == nil {
203 http.Error(w, "Forbidden", http.StatusUnauthorized)
204 return
205 }
206
207 count, err := db.CountNotifications(
208 n.db,
209 orm.FilterEq("recipient_did", user.Did),
210 orm.FilterEq("read", 0),
211 )
212 if err != nil {
213 http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
214 return
215 }
216
217 params := pages.NotificationCountParams{
218 Count: count,
219 }
220 err = n.pages.NotificationCount(w, params)
221 if err != nil {
222 http.Error(w, "Failed to render count", http.StatusInternalServerError)
223 return
224 }
225}
226
227func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) {
228 n.toggleRead(w, r, true)
229}
230
231func (n *Notifications) markUnread(w http.ResponseWriter, r *http.Request) {
232 n.toggleRead(w, r, false)
233}
234
235func (n *Notifications) toggleRead(w http.ResponseWriter, r *http.Request, read bool) {
236 l := n.logger.With("handler", "toggleRead")
237 userDid := n.oauth.GetDid(r)
238
239 idStr := chi.URLParam(r, "id")
240 notificationID, err := strconv.ParseInt(idStr, 10, 64)
241 if err != nil {
242 http.Error(w, "Invalid notification ID", http.StatusBadRequest)
243 return
244 }
245
246 if read {
247 err = db.MarkNotificationRead(n.db, notificationID, userDid)
248 } else {
249 err = db.MarkNotificationUnread(n.db, notificationID, userDid)
250 }
251 if err != nil {
252 http.Error(w, "Failed to update notification", http.StatusInternalServerError)
253 return
254 }
255
256 // if called via HTMX (has HX-Request header), return the updated item fragment
257 if r.Header.Get("HX-Request") == "true" {
258 notif, err := db.GetNotificationWithEntity(n.db, notificationID, userDid)
259 if err != nil {
260 l.Error("failed to fetch notification after toggle", "err", err)
261 http.Error(w, "Failed to fetch notification", http.StatusInternalServerError)
262 return
263 }
264 if err := n.pages.NotificationItem(w, notif); err != nil {
265 l.Error("failed to render notification item", "err", err)
266 }
267 return
268 }
269
270 w.WriteHeader(http.StatusNoContent)
271}
272
273func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
274 userDid := n.oauth.GetDid(r)
275
276 err := db.MarkAllNotificationsRead(n.db, userDid)
277 if err != nil {
278 http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
279 return
280 }
281
282 w.WriteHeader(http.StatusOK)
283}
284
285func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) {
286 userDid := n.oauth.GetDid(r)
287
288 idStr := chi.URLParam(r, "id")
289 notificationID, err := strconv.ParseInt(idStr, 10, 64)
290 if err != nil {
291 http.Error(w, "Invalid notification ID", http.StatusBadRequest)
292 return
293 }
294
295 err = db.DeleteNotification(n.db, notificationID, userDid)
296 if err != nil {
297 http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
298 return
299 }
300
301 w.WriteHeader(http.StatusOK)
302}