From 685e1d104fc7179d4755b60aef823f29eb753c10 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 5 Jun 2026 05:31:46 -0700 Subject: [PATCH] feat: add GET notification by ID endpoint Expose read-only in-app notification details at GET /notifications/:id with owner scoping and list-all admin access. Co-authored-by: Cursor --- internal/ports/notification.go | 1 + internal/repository/notification.go | 16 ++++ internal/services/notification/errors.go | 8 ++ internal/services/notification/service.go | 23 +++++ .../services/notification/service_get_test.go | 93 +++++++++++++++++++ .../handlers/notification_handler.go | 69 ++++++++++++++ internal/web_server/routes.go | 1 + 7 files changed, 211 insertions(+) create mode 100644 internal/services/notification/errors.go create mode 100644 internal/services/notification/service_get_test.go diff --git a/internal/ports/notification.go b/internal/ports/notification.go index 229fb1f..ef54f8d 100644 --- a/internal/ports/notification.go +++ b/internal/ports/notification.go @@ -7,6 +7,7 @@ import ( ) type NotificationStore interface { + GetNotification(ctx context.Context, id int64) (*domain.Notification, error) GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error) CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) diff --git a/internal/repository/notification.go b/internal/repository/notification.go index 775e30f..6ad3923 100644 --- a/internal/repository/notification.go +++ b/internal/repository/notification.go @@ -3,12 +3,14 @@ package repository import ( "context" "encoding/json" + "errors" "strconv" dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/ports" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -53,6 +55,20 @@ func (r *Store) CreateNotification( Read ========================= */ +func (r *Store) GetNotification( + ctx context.Context, + id int64, +) (*domain.Notification, error) { + dbNotif, err := r.queries.GetNotification(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, pgx.ErrNoRows + } + return nil, err + } + return mapDBToDomain(&dbNotif), nil +} + func (r *Store) GetUserNotifications( ctx context.Context, userID int64, diff --git a/internal/services/notification/errors.go b/internal/services/notification/errors.go new file mode 100644 index 0000000..1d30a08 --- /dev/null +++ b/internal/services/notification/errors.go @@ -0,0 +1,8 @@ +package notificationservice + +import "errors" + +var ( + ErrNotificationNotFound = errors.New("notification not found") + ErrNotificationForbidden = errors.New("notification access denied") +) diff --git a/internal/services/notification/service.go b/internal/services/notification/service.go index 726f0b9..6b33623 100644 --- a/internal/services/notification/service.go +++ b/internal/services/notification/service.go @@ -10,6 +10,7 @@ import ( "Yimaru-Backend/internal/web_server/ws" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -26,6 +27,7 @@ import ( firebase "firebase.google.com/go/v4" "firebase.google.com/go/v4/messaging" "github.com/gorilla/websocket" + "github.com/jackc/pgx/v5" "github.com/resend/resend-go/v2" "go.uber.org/zap" "google.golang.org/api/option" @@ -341,6 +343,27 @@ func (s *Service) SendNotification(ctx context.Context, notification *domain.Not // return nil // } +func (s *Service) GetNotificationByID(ctx context.Context, id, requesterID int64, allowAll bool) (*domain.Notification, error) { + notification, err := s.store.GetNotification(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotificationNotFound + } + s.mongoLogger.Error("[NotificationSvc.GetNotificationByID] Failed to get notification", + zap.Int64("notificationID", id), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return nil, err + } + + if !allowAll && notification.RecipientID != requesterID { + return nil, ErrNotificationForbidden + } + + return notification, nil +} + func (s *Service) GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error) { notifications, total, err := s.store.GetUserNotifications(ctx, recipientID, limit, offset) if err != nil { diff --git a/internal/services/notification/service_get_test.go b/internal/services/notification/service_get_test.go new file mode 100644 index 0000000..5d5a1f3 --- /dev/null +++ b/internal/services/notification/service_get_test.go @@ -0,0 +1,93 @@ +package notificationservice + +import ( + "context" + "errors" + "testing" + + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + + "github.com/jackc/pgx/v5" +) + +type getNotificationStoreStub struct { + ports.NotificationStore + notification *domain.Notification + err error +} + +func (s *getNotificationStoreStub) GetNotification(_ context.Context, id int64) (*domain.Notification, error) { + if s.err != nil { + return nil, s.err + } + if s.notification == nil { + return nil, pgx.ErrNoRows + } + return s.notification, nil +} + +func TestGetNotificationByID_ownNotification(t *testing.T) { + svc := &Service{ + store: &getNotificationStoreStub{ + notification: &domain.Notification{ + ID: "10", + RecipientID: 42, + }, + }, + } + + got, err := svc.GetNotificationByID(context.Background(), 10, 42, false) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if got.RecipientID != 42 { + t.Fatalf("expected recipient 42, got %d", got.RecipientID) + } +} + +func TestGetNotificationByID_forbiddenForOtherUser(t *testing.T) { + svc := &Service{ + store: &getNotificationStoreStub{ + notification: &domain.Notification{ + ID: "10", + RecipientID: 99, + }, + }, + } + + _, err := svc.GetNotificationByID(context.Background(), 10, 42, false) + if !errors.Is(err, ErrNotificationForbidden) { + t.Fatalf("expected ErrNotificationForbidden, got %v", err) + } +} + +func TestGetNotificationByID_adminCanReadAny(t *testing.T) { + svc := &Service{ + store: &getNotificationStoreStub{ + notification: &domain.Notification{ + ID: "10", + RecipientID: 99, + }, + }, + } + + got, err := svc.GetNotificationByID(context.Background(), 10, 42, true) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if got.RecipientID != 99 { + t.Fatalf("expected recipient 99, got %d", got.RecipientID) + } +} + +func TestGetNotificationByID_notFound(t *testing.T) { + svc := &Service{ + store: &getNotificationStoreStub{}, + } + + _, err := svc.GetNotificationByID(context.Background(), 10, 42, false) + if !errors.Is(err, ErrNotificationNotFound) { + t.Fatalf("expected ErrNotificationNotFound, got %v", err) + } +} diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index 96f3bc2..32aa316 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -2,6 +2,7 @@ package handlers import ( "Yimaru-Backend/internal/domain" + notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/web_server/ws" "bufio" "context" @@ -120,6 +121,74 @@ func (w *hijackResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { return w.conn, w.brw, nil } +// GetNotificationByID godoc +// @Summary Get in-app notification by ID +// @Description Returns a single in-app notification. Users may only fetch their own notifications unless they have list-all access. +// @Tags notifications +// @Produce json +// @Param id path int true "Notification ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/notifications/{id} [get] +func (h *Handler) GetNotificationByID(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid notification ID", + Error: err.Error(), + }) + } + + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Invalid user identification", + Error: "User ID not found in context", + }) + } + + role, _ := c.Locals("role").(domain.Role) + allowAll := h.rbacSvc.HasPermission(string(role), "notifications.list_all") + + notification, err := h.notificationSvc.GetNotificationByID(c.Context(), id, userID, allowAll) + if err != nil { + switch { + case errors.Is(err, notificationservice.ErrNotificationNotFound): + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Notification not found", + Error: err.Error(), + }) + case errors.Is(err, notificationservice.ErrNotificationForbidden): + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Notification access denied", + Error: err.Error(), + }) + default: + h.mongoLoggerSvc.Error("[NotificationHandler.GetNotificationByID] Failed to get notification", + zap.Int64("notificationID", id), + zap.Int64("userID", userID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get notification", + Error: err.Error(), + }) + } + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Notification retrieved successfully", + Success: true, + StatusCode: fiber.StatusOK, + Data: notification, + }) +} + func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 00d5517..cb0a52d 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -389,6 +389,7 @@ func (a *App) initAppRoutes() { groupV1.Get("/notifications/scheduled", a.authMiddleware, a.RequirePermission("notifications_scheduled.list"), h.ListScheduledNotifications) groupV1.Get("/notifications/scheduled/:id", a.authMiddleware, a.RequirePermission("notifications_scheduled.get"), h.GetScheduledNotification) groupV1.Post("/notifications/scheduled/:id/cancel", a.authMiddleware, a.RequirePermission("notifications_scheduled.cancel"), h.CancelScheduledNotification) + groupV1.Get("/notifications/:id", a.authMiddleware, a.RequirePermission("notifications.list_mine"), h.GetNotificationByID) // Issues groupV1.Post("/issues", a.authMiddleware, a.RequirePermission("issues.create"), h.CreateIssue)