package notifications import ( "log/slog" "net/http" "strconv" "github.com/go-chi/chi/v5" "tangled.org/core/appview/db" "tangled.org/core/appview/middleware" "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/pagination" "tangled.org/core/orm" ) type Notifications struct { db *db.DB oauth *oauth.OAuth pages *pages.Pages logger *slog.Logger } func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications { return &Notifications{ db: database, oauth: oauthHandler, pages: pagesHandler, logger: logger, } } func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { r := chi.NewRouter() r.Get("/count", n.getUnreadCount) r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(n.oauth)) r.With(middleware.Paginate).Get("/", n.notificationsPage) r.Get("/preview", n.previewHandler) r.Post("/{id}/read", n.markRead) r.Post("/{id}/unread", n.markUnread) r.Post("/read-all", n.markAllRead) r.Delete("/{id}", n.deleteNotification) }) return r } func notificationFilters(r *http.Request, userDid string) (filters []orm.Filter, readFilter, categoryFilter string) { filters = []orm.Filter{orm.FilterEq("recipient_did", userDid)} readFilter = r.URL.Query().Get("read") if readFilter != "unread" { readFilter = "inbox" } if readFilter == "unread" { filters = append(filters, orm.FilterEq("read", 0)) } categoryFilter = r.URL.Query().Get("category") switch categoryFilter { case "social": filters = append(filters, orm.FilterIn("type", models.SocialNotificationTypes)) case "work": filters = append(filters, orm.FilterIn("type", models.WorkNotificationTypes)) default: categoryFilter = "all" } return filters, readFilter, categoryFilter } func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { l := n.logger.With("handler", "notificationsPage") user := n.oauth.GetMultiAccountUser(r) page := pagination.FromContext(r.Context()) filters, readFilter, categoryFilter := notificationFilters(r, user.Did) // mobile: respects category filter mobileTotal, err := db.CountNotifications(n.db, filters...) if err != nil { l.Error("failed to get total notifications", "err", err) n.pages.Error500(w) return } notifications, err := db.GetNotificationsWithEntities(n.db, page, filters...) if err != nil { l.Error("failed to get notifications", "err", err) n.pages.Error500(w) return } // desktop columns: category is fixed, only read filter applies readFilters := []orm.Filter{orm.FilterEq("recipient_did", user.Did)} if readFilter == "unread" { readFilters = append(readFilters, orm.FilterEq("read", 0)) } workTotal, err := db.CountNotifications(n.db, append(readFilters, orm.FilterIn("type", models.WorkNotificationTypes))..., ) if err != nil { l.Error("failed to count work notifications", "err", err) n.pages.Error500(w) return } workNotifications, err := db.GetNotificationsWithEntities(n.db, page, append(readFilters, orm.FilterIn("type", models.WorkNotificationTypes))..., ) if err != nil { l.Error("failed to get work notifications", "err", err) n.pages.Error500(w) return } socialTotal, err := db.CountNotifications(n.db, append(readFilters, orm.FilterIn("type", models.SocialNotificationTypes))..., ) if err != nil { l.Error("failed to count social notifications", "err", err) n.pages.Error500(w) return } socialNotifications, err := db.GetNotificationsWithEntities(n.db, page, append(readFilters, orm.FilterIn("type", models.SocialNotificationTypes))..., ) if err != nil { l.Error("failed to get social notifications", "err", err) n.pages.Error500(w) return } // shared pagination total: max of all relevant counts total := int(max(socialTotal, max(workTotal, mobileTotal))) unreadBase := []orm.Filter{ orm.FilterEq("recipient_did", user.Did), orm.FilterEq("read", 0), } workUnreadCount, err := db.CountNotifications(n.db, append(unreadBase, orm.FilterIn("type", models.WorkNotificationTypes))..., ) if err != nil { l.Error("failed to count work unread", "err", err) } socialUnreadCount, err := db.CountNotifications(n.db, append(unreadBase, orm.FilterIn("type", models.SocialNotificationTypes))..., ) if err != nil { l.Error("failed to count social unread", "err", err) } focusCount, err := db.CountFocusNotifs(n.db, user.Did) if err != nil { l.Error("failed to count focus notifs", "err", err) } err = n.pages.Notifications(w, pages.NotificationsParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), MobileGroups: pages.GroupNotificationsByDate(notifications), WorkGroups: pages.GroupNotificationsByDate(workNotifications), SocialGroups: pages.GroupNotificationsByDate(socialNotifications), WorkUnreadCount: workUnreadCount, SocialUnreadCount: socialUnreadCount, Page: page, Total: total, ReadFilter: readFilter, CategoryFilter: categoryFilter, CanFocus: focusCount > 1, }) if err != nil { l.Error("failed to render page", "err", err) } } func (n *Notifications) previewHandler(w http.ResponseWriter, r *http.Request) { l := n.logger.With("handler", "previewHandler") user := n.oauth.GetMultiAccountUser(r) filters, readFilter, categoryFilter := notificationFilters(r, user.Did) notifications, err := db.GetNotificationsWithEntities( n.db, pagination.Page{Limit: 5, Offset: 0}, filters..., ) if err != nil { l.Error("failed to get notifications", "err", err) n.pages.Error500(w) return } focusCount, err := db.CountFocusNotifs(n.db, user.Did) if err != nil { l.Error("failed to count focus notifs", "err", err) } err = n.pages.NotificationPreview(w, pages.NotificationPreviewParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), Notifications: notifications, ReadFilter: readFilter, CategoryFilter: categoryFilter, CanFocus: focusCount > 1, }) if err != nil { l.Error("failed to render notification preview", "err", err) } } func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { user := n.oauth.GetMultiAccountUser(r) if user == nil { http.Error(w, "Forbidden", http.StatusUnauthorized) return } count, err := db.CountNotifications( n.db, orm.FilterEq("recipient_did", user.Did), orm.FilterEq("read", 0), ) if err != nil { http.Error(w, "Failed to get unread count", http.StatusInternalServerError) return } params := pages.NotificationCountParams{ Count: count, } err = n.pages.NotificationCount(w, params) if err != nil { http.Error(w, "Failed to render count", http.StatusInternalServerError) return } } func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { n.toggleRead(w, r, true) } func (n *Notifications) markUnread(w http.ResponseWriter, r *http.Request) { n.toggleRead(w, r, false) } func (n *Notifications) toggleRead(w http.ResponseWriter, r *http.Request, read bool) { l := n.logger.With("handler", "toggleRead") userDid := n.oauth.GetDid(r) idStr := chi.URLParam(r, "id") notificationID, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid notification ID", http.StatusBadRequest) return } if read { err = db.MarkNotificationRead(n.db, notificationID, userDid) } else { err = db.MarkNotificationUnread(n.db, notificationID, userDid) } if err != nil { n.logger.Error("failed to toggle notification read state", "err", err) http.Error(w, "Failed to update notification", http.StatusInternalServerError) return } // if called via HTMX (has HX-Request header), return the updated item fragment if r.Header.Get("HX-Request") == "true" { notif, err := db.GetNotificationWithEntity(n.db, notificationID, userDid) if err != nil { l.Error("failed to fetch notification after toggle", "err", err) http.Error(w, "Failed to fetch notification", http.StatusInternalServerError) return } if err := n.pages.NotificationItem(w, notif); err != nil { l.Error("failed to render notification item", "err", err) } return } w.WriteHeader(http.StatusNoContent) } func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { userDid := n.oauth.GetDid(r) err := db.MarkAllNotificationsRead(n.db, userDid) if err != nil { n.logger.Error("failed to mark all notifications as read", "err", err) http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) { userDid := n.oauth.GetDid(r) idStr := chi.URLParam(r, "id") notificationID, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid notification ID", http.StatusBadRequest) return } err = db.DeleteNotification(n.db, notificationID, userDid) if err != nil { http.Error(w, "Failed to delete notification", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) }