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 {
|
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)
|
GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error)
|
||||||
CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error)
|
CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error)
|
||||||
GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error)
|
GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ package repository
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
dbgen "Yimaru-Backend/gen/db"
|
dbgen "Yimaru-Backend/gen/db"
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"Yimaru-Backend/internal/ports"
|
"Yimaru-Backend/internal/ports"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -53,6 +55,20 @@ func (r *Store) CreateNotification(
|
||||||
Read
|
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(
|
func (r *Store) GetUserNotifications(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID int64,
|
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"
|
"Yimaru-Backend/internal/web_server/ws"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -26,6 +27,7 @@ import (
|
||||||
firebase "firebase.google.com/go/v4"
|
firebase "firebase.google.com/go/v4"
|
||||||
"firebase.google.com/go/v4/messaging"
|
"firebase.google.com/go/v4/messaging"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/resend/resend-go/v2"
|
"github.com/resend/resend-go/v2"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/api/option"
|
"google.golang.org/api/option"
|
||||||
|
|
@ -341,6 +343,27 @@ func (s *Service) SendNotification(ctx context.Context, notification *domain.Not
|
||||||
// return nil
|
// 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) {
|
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)
|
notifications, total, err := s.store.GetUserNotifications(ctx, recipientID, limit, offset)
|
||||||
if err != nil {
|
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 (
|
import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
|
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||||
"Yimaru-Backend/internal/web_server/ws"
|
"Yimaru-Backend/internal/web_server/ws"
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -120,6 +121,74 @@ func (w *hijackResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
return w.conn, w.brw, nil
|
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 {
|
func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error {
|
||||||
idStr := c.Params("id")
|
idStr := c.Params("id")
|
||||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
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", a.authMiddleware, a.RequirePermission("notifications_scheduled.list"), h.ListScheduledNotifications)
|
||||||
groupV1.Get("/notifications/scheduled/:id", a.authMiddleware, a.RequirePermission("notifications_scheduled.get"), h.GetScheduledNotification)
|
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.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
|
// Issues
|
||||||
groupV1.Post("/issues", a.authMiddleware, a.RequirePermission("issues.create"), h.CreateIssue)
|
groupV1.Post("/issues", a.authMiddleware, a.RequirePermission("issues.create"), h.CreateIssue)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user