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 <cursoragent@cursor.com>
This commit is contained in:
parent
bb03ee1668
commit
685e1d104f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
8
internal/services/notification/errors.go
Normal file
8
internal/services/notification/errors.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package notificationservice
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotificationNotFound = errors.New("notification not found")
|
||||
ErrNotificationForbidden = errors.New("notification access denied")
|
||||
)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
93
internal/services/notification/service_get_test.go
Normal file
93
internal/services/notification/service_get_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user