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:
Yared Yemane 2026-06-05 05:31:46 -07:00
parent bb03ee1668
commit 685e1d104f
7 changed files with 211 additions and 0 deletions

View File

@ -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)

View File

@ -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,

View File

@ -0,0 +1,8 @@
package notificationservice
import "errors"
var (
ErrNotificationNotFound = errors.New("notification not found")
ErrNotificationForbidden = errors.New("notification access denied")
)

View File

@ -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 {

View 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)
}
}

View File

@ -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)

View File

@ -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)