diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 5ceec22..9d68221 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -199,6 +199,7 @@ func run() error { BookWriter: store, AIJobStore: store, BookAdminStore: store, + NotificationStore: store, Log: log, }, ) diff --git a/backend/internal/backend/handlers_notifications.go b/backend/internal/backend/handlers_notifications.go index 1216c83..d52b11a 100644 --- a/backend/internal/backend/handlers_notifications.go +++ b/backend/internal/backend/handlers_notifications.go @@ -3,8 +3,6 @@ package backend import ( "encoding/json" "net/http" - - "github.com/libnovel/backend/internal/storage" ) // handleDismissNotification handles DELETE /api/notifications/{id}. @@ -14,12 +12,11 @@ func (s *Server) handleDismissNotification(w http.ResponseWriter, r *http.Reques jsonError(w, http.StatusBadRequest, "notification id required") return } - store, ok := s.deps.Producer.(*storage.Store) - if !ok { - jsonError(w, http.StatusInternalServerError, "storage not available") + if s.deps.NotificationStore == nil { + jsonError(w, http.StatusServiceUnavailable, "notification store not configured") return } - if err := store.DeleteNotification(r.Context(), id); err != nil { + if err := s.deps.NotificationStore.DeleteNotification(r.Context(), id); err != nil { jsonError(w, http.StatusInternalServerError, "dismiss notification: "+err.Error()) return } @@ -33,12 +30,11 @@ func (s *Server) handleClearAllNotifications(w http.ResponseWriter, r *http.Requ jsonError(w, http.StatusBadRequest, "user_id required") return } - store, ok := s.deps.Producer.(*storage.Store) - if !ok { - jsonError(w, http.StatusInternalServerError, "storage not available") + if s.deps.NotificationStore == nil { + jsonError(w, http.StatusServiceUnavailable, "notification store not configured") return } - if err := store.ClearAllNotifications(r.Context(), userID); err != nil { + if err := s.deps.NotificationStore.ClearAllNotifications(r.Context(), userID); err != nil { jsonError(w, http.StatusInternalServerError, "clear notifications: "+err.Error()) return } @@ -52,12 +48,11 @@ func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.R jsonError(w, http.StatusBadRequest, "user_id required") return } - store, ok := s.deps.Producer.(*storage.Store) - if !ok { - jsonError(w, http.StatusInternalServerError, "storage not available") + if s.deps.NotificationStore == nil { + jsonError(w, http.StatusServiceUnavailable, "notification store not configured") return } - if err := store.MarkAllNotificationsRead(r.Context(), userID); err != nil { + if err := s.deps.NotificationStore.MarkAllNotificationsRead(r.Context(), userID); err != nil { jsonError(w, http.StatusInternalServerError, "mark all read: "+err.Error()) return } @@ -80,13 +75,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) return } - store, ok := s.deps.Producer.(*storage.Store) - if !ok { - jsonError(w, http.StatusInternalServerError, "storage not available") + if s.deps.NotificationStore == nil { + jsonError(w, http.StatusServiceUnavailable, "notification store not configured") return } - items, err := store.ListNotifications(r.Context(), userID, 50) + items, err := s.deps.NotificationStore.ListNotifications(r.Context(), userID, 50) if err != nil { jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error()) return @@ -97,7 +91,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) for _, item := range items { b, _ := json.Marshal(item) var n notification - json.Unmarshal(b, &n) + json.Unmarshal(b, &n) //nolint:errcheck notifications = append(notifications, n) } @@ -111,16 +105,15 @@ func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Reque return } - store, ok := s.deps.Producer.(*storage.Store) - if !ok { - jsonError(w, http.StatusInternalServerError, "storage not available") + if s.deps.NotificationStore == nil { + jsonError(w, http.StatusServiceUnavailable, "notification store not configured") return } - if err := store.MarkNotificationRead(r.Context(), id); err != nil { + if err := s.deps.NotificationStore.MarkNotificationRead(r.Context(), id); err != nil { jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error()) return } writeJSON(w, 0, map[string]any{"success": true}) -} \ No newline at end of file +} diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index b6e087c..a26f1f6 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -94,6 +94,10 @@ type Dependencies struct { // BookAdminStore provides admin-only operations: archive, unarchive, hard-delete. // If nil, the admin book management endpoints return 503. BookAdminStore bookstore.BookAdminStore + // NotificationStore manages per-user in-app notifications. + // Always wired directly to *storage.Store (not the Asynq wrapper) so + // notification endpoints work regardless of whether Redis/Asynq is in use. + NotificationStore bookstore.NotificationStore // Log is the structured logger. Log *slog.Logger } diff --git a/backend/internal/bookstore/bookstore.go b/backend/internal/bookstore/bookstore.go index 48d534a..c9fd468 100644 --- a/backend/internal/bookstore/bookstore.go +++ b/backend/internal/bookstore/bookstore.go @@ -247,3 +247,14 @@ type ImportFileStore interface { // GetImportChapters retrieves the pre-parsed chapters JSON. GetImportChapters(ctx context.Context, key string) ([]byte, error) } + +// NotificationStore manages per-user in-app notifications. +// Always wired directly to the concrete *storage.Store so it works +// regardless of whether the Asynq task-queue wrapper is in use. +type NotificationStore interface { + ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) + MarkNotificationRead(ctx context.Context, id string) error + MarkAllNotificationsRead(ctx context.Context, userID string) error + DeleteNotification(ctx context.Context, id string) error + ClearAllNotifications(ctx context.Context, userID string) error +} diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index 8e262ab..6a174a9 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -773,7 +773,7 @@ func (s *Store) CreateNotification(ctx context.Context, userID, title, message, // ListNotifications returns notifications for a user. func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) { - filter := fmt.Sprintf("user_id='%s'", userID) + filter := fmt.Sprintf(`user_id="%s"`, userID) items, err := s.pb.listAll(ctx, "notifications", filter, "-created") if err != nil { return nil, err @@ -805,7 +805,7 @@ func (s *Store) DeleteNotification(ctx context.Context, id string) error { // ClearAllNotifications deletes all notifications for a user. func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error { - filter := fmt.Sprintf("user_id='%s'", userID) + filter := fmt.Sprintf(`user_id="%s"`, userID) items, err := s.pb.listAll(ctx, "notifications", filter, "") if err != nil { return fmt.Errorf("ClearAllNotifications list: %w", err) @@ -823,7 +823,7 @@ func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error // MarkAllNotificationsRead marks all notifications for a user as read. func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error { - filter := fmt.Sprintf("user_id='%s'&&read=false", userID) + filter := fmt.Sprintf(`user_id="%s"&&read=false`, userID) items, err := s.pb.listAll(ctx, "notifications", filter, "") if err != nil { return fmt.Errorf("MarkAllNotificationsRead list: %w", err)