diff --git a/backend/modules/notifications/connectors/repository.go b/backend/modules/notifications/connectors/repository.go index 36266ee92..e21f3e21b 100644 --- a/backend/modules/notifications/connectors/repository.go +++ b/backend/modules/notifications/connectors/repository.go @@ -11,6 +11,7 @@ type NotificationRepository interface { Save(ctx context.Context, n *domain.UtmNotification) error FindByID(ctx context.Context, id int64) (*domain.UtmNotification, error) FindAll(ctx context.Context, q dto.NotificationListQuery) ([]domain.UtmNotification, int64, error) + FindAllGrouped(ctx context.Context, q dto.NotificationListQuery) ([]domain.NotificationGroup, int64, error) UpdateRead(ctx context.Context, id int64, read bool) (*domain.UtmNotification, error) UpdateStatus(ctx context.Context, id int64, status domain.NotificationStatus) (*domain.UtmNotification, error) MarkAllRead(ctx context.Context) (int64, error) diff --git a/backend/modules/notifications/connectors/usecase.go b/backend/modules/notifications/connectors/usecase.go index f74025205..28f492590 100644 --- a/backend/modules/notifications/connectors/usecase.go +++ b/backend/modules/notifications/connectors/usecase.go @@ -10,6 +10,7 @@ import ( type NotificationUsecase interface { Create(ctx context.Context, req dto.CreateNotificationRequest) (*domain.UtmNotification, error) List(ctx context.Context, q dto.NotificationListQuery) ([]domain.UtmNotification, int64, error) + ListGrouped(ctx context.Context, q dto.NotificationListQuery) ([]domain.NotificationGroup, int64, error) GetByID(ctx context.Context, id int64) (*domain.UtmNotification, error) MarkRead(ctx context.Context, id int64, read bool) (*domain.UtmNotification, error) UpdateStatus(ctx context.Context, id int64, status domain.NotificationStatus) (*domain.UtmNotification, error) diff --git a/backend/modules/notifications/domain/notification.go b/backend/modules/notifications/domain/notification.go index 9cef3d742..e3d0a09b8 100644 --- a/backend/modules/notifications/domain/notification.go +++ b/backend/modules/notifications/domain/notification.go @@ -16,3 +16,12 @@ type UtmNotification struct { } func (UtmNotification) TableName() string { return "utm_notification" } + +type NotificationGroup struct { + Source NotificationSource `json:"source"` + Type NotificationType `json:"type"` + Message string `json:"message"` + Count int64 `json:"count"` + LastCreated time.Time `json:"lastCreated"` + UnreadCount int64 `json:"unreadCount"` +} diff --git a/backend/modules/notifications/dto/filters.go b/backend/modules/notifications/dto/filters.go index ed76394ee..163d4d709 100644 --- a/backend/modules/notifications/dto/filters.go +++ b/backend/modules/notifications/dto/filters.go @@ -8,12 +8,13 @@ import ( ) type NotificationListQuery struct { - Source *domain.NotificationSource - Type *domain.NotificationType - Status *domain.NotificationStatus - From *time.Time - To *time.Time - Read *bool + Source *domain.NotificationSource + Type *domain.NotificationType + Status *domain.NotificationStatus + Message *string + From *time.Time + To *time.Time + Read *bool database.Params Sort string } diff --git a/backend/modules/notifications/handler/helpers.go b/backend/modules/notifications/handler/helpers.go index 24b9bf3df..d86954839 100644 --- a/backend/modules/notifications/handler/helpers.go +++ b/backend/modules/notifications/handler/helpers.go @@ -126,6 +126,14 @@ func queryType(c *gin.Context, key string) *domain.NotificationType { return &t } +func queryString(c *gin.Context, key string) *string { + v := c.Query(key) + if v == "" { + return nil + } + return &v +} + func queryStatus(c *gin.Context, key string) *domain.NotificationStatus { v := c.Query(key) if v == "" { diff --git a/backend/modules/notifications/handler/notifications.go b/backend/modules/notifications/handler/notifications.go index 428c8639d..22e1a284b 100644 --- a/backend/modules/notifications/handler/notifications.go +++ b/backend/modules/notifications/handler/notifications.go @@ -47,6 +47,49 @@ func (h *NotificationHandler) Create(c *gin.Context) { // @Tags Notifications // @Security BearerAuth // @Produce json +// @Param source query string false "Filter by source" +// @Param type query string false "Filter by type" +// @Param status query string false "Filter by status" +// @Param message query string false "Filter by exact message" +// @Param read query bool false "Filter by read flag" +// @Param from query string false "Created at >= (RFC3339)" +// @Param to query string false "Created at <= (RFC3339)" +// @Param page query int false "Page (default 1)" +// @Param size query int false "Page size (default 20, max 200)" +// @Param sort query string false "field,asc|desc" +// @Success 200 {array} dto.NotificationResponse +// @Header 200 {string} X-Total-Count "Total items" +// @Failure 500 {object} map[string]string +// @Router /notifications [get] +func (h *NotificationHandler) List(c *gin.Context) { + q := dto.NotificationListQuery{ + Source: querySource(c, "source"), + Type: queryType(c, "type"), + Status: queryStatus(c, "status"), + Message: queryString(c, "message"), + Read: queryBool(c, "read"), + From: queryTime(c, "from"), + To: queryTime(c, "to"), + Params: database.Params{Page: queryInt(c, "page", 0), Size: queryInt(c, "size", 20)}, + Sort: c.Query("sort"), + } + rows, total, err := h.usecase.List(c.Request.Context(), q) + if err != nil { + writeNotificationError(c, err) + return + } + resp := make([]dto.NotificationResponse, len(rows)) + for i := range rows { + resp[i] = dto.FromEntity(&rows[i]) + } + page, size := q.Normalized() + writePagedArray(c, resp, total, page, size) +} + +// @Summary List notifications grouped by source, type and message +// @Tags Notifications +// @Security BearerAuth +// @Produce json // @Param source query string false "Filter by source" // @Param type query string false "Filter by type" // @Param status query string false "Filter by status" @@ -55,12 +98,11 @@ func (h *NotificationHandler) Create(c *gin.Context) { // @Param to query string false "Created at <= (RFC3339)" // @Param page query int false "Page (default 1)" // @Param size query int false "Page size (default 20, max 200)" -// @Param sort query string false "field,asc|desc" -// @Success 200 {array} dto.NotificationResponse -// @Header 200 {string} X-Total-Count "Total items" +// @Success 200 {array} domain.NotificationGroup +// @Header 200 {string} X-Total-Count "Total groups" // @Failure 500 {object} map[string]string -// @Router /notifications [get] -func (h *NotificationHandler) List(c *gin.Context) { +// @Router /notifications/grouped [get] +func (h *NotificationHandler) ListGrouped(c *gin.Context) { q := dto.NotificationListQuery{ Source: querySource(c, "source"), Type: queryType(c, "type"), @@ -69,19 +111,14 @@ func (h *NotificationHandler) List(c *gin.Context) { From: queryTime(c, "from"), To: queryTime(c, "to"), Params: database.Params{Page: queryInt(c, "page", 0), Size: queryInt(c, "size", 20)}, - Sort: c.Query("sort"), } - rows, total, err := h.usecase.List(c.Request.Context(), q) + rows, total, err := h.usecase.ListGrouped(c.Request.Context(), q) if err != nil { writeNotificationError(c, err) return } - resp := make([]dto.NotificationResponse, len(rows)) - for i := range rows { - resp[i] = dto.FromEntity(&rows[i]) - } page, size := q.Normalized() - writePagedArray(c, resp, total, page, size) + writePagedArray(c, rows, total, page, size) } // @Summary Get notification by ID diff --git a/backend/modules/notifications/repository/notification_pg.go b/backend/modules/notifications/repository/notification_pg.go index 09895fec4..eb3c987ca 100644 --- a/backend/modules/notifications/repository/notification_pg.go +++ b/backend/modules/notifications/repository/notification_pg.go @@ -47,6 +47,9 @@ func (r *pgNotificationRepository) FindAll(ctx context.Context, q dto.Notificati if q.Status != nil { db = db.Where("status = ?", *q.Status) } + if q.Message != nil { + db = db.Where("message = ?", *q.Message) + } if q.Read != nil { db = db.Where("read = ?", *q.Read) } @@ -78,6 +81,56 @@ func (r *pgNotificationRepository) FindAll(ctx context.Context, q dto.Notificati return rows, total, nil } +func (r *pgNotificationRepository) FindAllGrouped(ctx context.Context, q dto.NotificationListQuery) ([]domain.NotificationGroup, int64, error) { + base := r.db.WithContext(ctx).Model(&domain.UtmNotification{}) + + if q.Source != nil { + base = base.Where("source = ?", *q.Source) + } + if q.Type != nil { + base = base.Where("type = ?", *q.Type) + } + if q.Status != nil { + base = base.Where("status = ?", *q.Status) + } + if q.Read != nil { + base = base.Where("read = ?", *q.Read) + } + if q.From != nil && q.To != nil { + base = base.Where("created_at BETWEEN ? AND ?", *q.From, *q.To) + } else if q.From != nil { + base = base.Where("created_at >= ?", *q.From) + } else if q.To != nil { + base = base.Where("created_at <= ?", *q.To) + } + + countQ := base.Session(&gorm.Session{}). + Select("source, type, message"). + Group("source, type, message") + + var total int64 + if err := r.db.WithContext(ctx). + Table("(?) as g", countQ). + Count(&total).Error; err != nil { + return nil, 0, err + } + + var rows []domain.NotificationGroup + if err := base.Session(&gorm.Session{}). + Select(`source, type, message, + COUNT(*) AS count, + MAX(created_at) AS last_created, + SUM(CASE WHEN read = false THEN 1 ELSE 0 END) AS unread_count`). + Group("source, type, message"). + Order("last_created DESC"). + Offset(q.Offset()). + Limit(q.Limit()). + Scan(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + func (r *pgNotificationRepository) UpdateRead(ctx context.Context, id int64, read bool) (*domain.UtmNotification, error) { row, err := r.FindByID(ctx, id) if err != nil { diff --git a/backend/modules/notifications/routes.go b/backend/modules/notifications/routes.go index 62911e24a..4048d1849 100644 --- a/backend/modules/notifications/routes.go +++ b/backend/modules/notifications/routes.go @@ -12,6 +12,7 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { g.POST("", middleware.RequireInternal(), nh.Create) g.GET("", nh.List) + g.GET("/grouped", nh.ListGrouped) g.GET("/unread-count", nh.UnreadCount) g.PUT("/read-all", nh.MarkAllRead) g.GET("/:id", nh.GetByID) diff --git a/backend/modules/notifications/usecase/notification.go b/backend/modules/notifications/usecase/notification.go index 3ddffa866..3a421bd52 100644 --- a/backend/modules/notifications/usecase/notification.go +++ b/backend/modules/notifications/usecase/notification.go @@ -51,6 +51,10 @@ func (u *notificationUsecase) List(ctx context.Context, q dto.NotificationListQu return u.repo.FindAll(ctx, q) } +func (u *notificationUsecase) ListGrouped(ctx context.Context, q dto.NotificationListQuery) ([]domain.NotificationGroup, int64, error) { + return u.repo.FindAllGrouped(ctx, q) +} + func (u *notificationUsecase) GetByID(ctx context.Context, id int64) (*domain.UtmNotification, error) { row, err := u.repo.FindByID(ctx, id) if err != nil { diff --git a/frontend/src/features/notifications/components/NotificationGroupItems.tsx b/frontend/src/features/notifications/components/NotificationGroupItems.tsx new file mode 100644 index 000000000..43f2371e8 --- /dev/null +++ b/frontend/src/features/notifications/components/NotificationGroupItems.tsx @@ -0,0 +1,87 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { Loader2 } from 'lucide-react' +import { useNotificationFeed } from '../hooks/useNotificationFeed' +import { useNotifications } from '../services/notifications.context' +import type { Notification, NotificationType } from '../types/notification.types' +import { NotificationRow } from './NotificationRow' + +interface NotificationGroupItemsProps { + source: string + type: NotificationType + message: string + pageSize?: number + maxHeightClass?: string + onItemChanged?: () => void +} + +export function NotificationGroupItems({ + source, + type, + message, + pageSize = 10, + maxHeightClass = 'max-h-72', + onItemChanged, +}: NotificationGroupItemsProps) { + const { t } = useTranslation() + const { markRead, remove, refreshUnread } = useNotifications() + const { items, setItems, loading, hasMore, error, loadMore } = useNotificationFeed(pageSize, { + source, + type, + message, + }) + + useEffect(() => { + void loadMore() + }, [loadMore]) + + const onScroll = (e: React.UIEvent) => { + const el = e.currentTarget + if (el.scrollHeight - el.scrollTop - el.clientHeight < 60 && hasMore && !loading) { + void loadMore() + } + } + + const toggleRead = async (n: Notification) => { + setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: !n.read } : x))) + try { + await markRead(n.id, !n.read) + onItemChanged?.() + } catch { + setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: n.read } : x))) + } + } + + const deleteItem = async (n: Notification) => { + setItems((prev) => prev.filter((x) => x.id !== n.id)) + try { + await remove(n.id) + onItemChanged?.() + } catch { + void refreshUnread() + } + } + + return ( +
+ {items.map((n) => ( + void toggleRead(x)} + onDelete={(x) => void deleteItem(x)} + /> + ))} + {loading && ( +
+ +
+ )} + {error && !loading && items.length === 0 && ( +
+ {t('notifications.loadFailed')} +
+ )} +
+ ) +} diff --git a/frontend/src/features/notifications/components/NotificationGroupRow.tsx b/frontend/src/features/notifications/components/NotificationGroupRow.tsx new file mode 100644 index 000000000..799298e05 --- /dev/null +++ b/frontend/src/features/notifications/components/NotificationGroupRow.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { TYPE_META, timeAgo } from '../lib' +import type { NotificationGroup } from '../types/notification.types' +import { NotificationGroupItems } from './NotificationGroupItems' + +interface NotificationGroupRowProps { + group: NotificationGroup + /** Notified when a nested item is toggled/deleted so the parent can refresh counts. */ + onChanged?: () => void + /** Height cap for the nested infinite-scroll list (Tailwind max-h-* class). */ + itemsMaxHeightClass?: string +} + +export function NotificationGroupRow({ + group, + onChanged, + itemsMaxHeightClass, +}: NotificationGroupRowProps) { + const [open, setOpen] = useState(false) + const meta = TYPE_META[group.type] ?? TYPE_META.INFO + const Icon = meta.icon + const expandable = group.count > 1 + const hasUnread = group.unreadCount > 0 + + return ( +
+
setOpen((v) => !v) : undefined} + role={expandable ? 'button' : undefined} + tabIndex={expandable ? 0 : undefined} + onKeyDown={ + expandable + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setOpen((v) => !v) + } + } + : undefined + } + > + + + +
+

+ {group.message} +

+
+ {group.source} + · + {timeAgo(group.lastCreated)} +
+
+ +
+ {group.count > 1 && ( + + {group.count} + + )} + {hasUnread && ( + + {group.unreadCount} + + )} + {expandable && ( + + )} +
+
+ + {expandable && open && ( + + )} +
+ ) +} diff --git a/frontend/src/features/notifications/hooks/useNotificationFeed.ts b/frontend/src/features/notifications/hooks/useNotificationFeed.ts index 3449e3750..2209fff9b 100644 --- a/frontend/src/features/notifications/hooks/useNotificationFeed.ts +++ b/frontend/src/features/notifications/hooks/useNotificationFeed.ts @@ -1,13 +1,22 @@ import { useCallback, useRef, useState } from 'react' import { notificationsHttpService } from '../services/notifications-http.service' -import type { Notification } from '../types/notification.types' +import type { + Notification, + NotificationType, +} from '../types/notification.types' + +interface FeedFilter { + source?: string + type?: NotificationType + message?: string +} /** * Infinite-scroll feed of ACTIVE notifications, paged `pageSize` at a time. - * "Has more" is inferred from the last batch size (the list endpoint returns a - * bare array, no total in the body). Newest first (backend default order). + * Optional `filter` narrows to a single group (source/type/message) for the + * expandable list inside NotificationGroupRow. Newest first. */ -export function useNotificationFeed(pageSize: number) { +export function useNotificationFeed(pageSize: number, filter?: FeedFilter) { const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [hasMore, setHasMore] = useState(true) @@ -27,6 +36,9 @@ export function useNotificationFeed(pageSize: number) { page: pageRef.current, size: pageSize, status: 'ACTIVE', + source: filter?.source, + type: filter?.type, + message: filter?.message, }) setItems((prev) => [...prev, ...batch]) pageRef.current += 1 @@ -40,7 +52,7 @@ export function useNotificationFeed(pageSize: number) { loadingRef.current = false setLoading(false) } - }, [pageSize]) + }, [pageSize, filter?.source, filter?.type, filter?.message]) const reset = useCallback(() => { pageRef.current = 0 diff --git a/frontend/src/features/notifications/hooks/useNotificationGroupFeed.ts b/frontend/src/features/notifications/hooks/useNotificationGroupFeed.ts new file mode 100644 index 000000000..e2291c1f1 --- /dev/null +++ b/frontend/src/features/notifications/hooks/useNotificationGroupFeed.ts @@ -0,0 +1,61 @@ +import { useCallback, useRef, useState } from 'react' +import { notificationsHttpService } from '../services/notifications-http.service' +import type { NotificationGroup } from '../types/notification.types' + +/** + * Infinite-scroll feed of notification groups (source/type/message + counts), + * paged `pageSize` at a time. Uses X-Total-Count from /notifications/grouped + * to know when to stop. Groups are ordered by most-recent lastCreated first. + */ +export function useNotificationGroupFeed(pageSize: number) { + const [groups, setGroups] = useState([]) + const [loading, setLoading] = useState(false) + const [hasMore, setHasMore] = useState(true) + const [error, setError] = useState(false) + + const pageRef = useRef(0) + const loadedRef = useRef(0) + const totalRef = useRef(null) + const loadingRef = useRef(false) + const doneRef = useRef(false) + + const loadMore = useCallback(async () => { + if (loadingRef.current || doneRef.current) return + loadingRef.current = true + setLoading(true) + setError(false) + try { + const { data, total } = await notificationsHttpService.listGrouped({ + page: pageRef.current, + size: pageSize, + status: 'ACTIVE', + }) + totalRef.current = total + loadedRef.current += data.length + setGroups((prev) => [...prev, ...data]) + pageRef.current += 1 + if (data.length < pageSize || loadedRef.current >= total) { + doneRef.current = true + setHasMore(false) + } + } catch { + setError(true) + } finally { + loadingRef.current = false + setLoading(false) + } + }, [pageSize]) + + const reset = useCallback(() => { + pageRef.current = 0 + loadedRef.current = 0 + totalRef.current = null + doneRef.current = false + loadingRef.current = false + setGroups([]) + setHasMore(true) + setError(false) + }, []) + + return { groups, setGroups, loading, hasMore, error, loadMore, reset } +} diff --git a/frontend/src/features/notifications/index.ts b/frontend/src/features/notifications/index.ts index 0089840eb..d2ae303fb 100644 --- a/frontend/src/features/notifications/index.ts +++ b/frontend/src/features/notifications/index.ts @@ -2,5 +2,7 @@ export * from './types/notification.types' export { notificationsHttpService } from './services/notifications-http.service' export { NotificationsProvider, useNotifications } from './services/notifications.context' export { useNotificationFeed } from './hooks/useNotificationFeed' +export { useNotificationGroupFeed } from './hooks/useNotificationGroupFeed' export { NotificationRow } from './components/NotificationRow' +export { NotificationGroupRow } from './components/NotificationGroupRow' export { NotificationsPage } from './pages/NotificationsPage' diff --git a/frontend/src/features/notifications/pages/NotificationsPage.tsx b/frontend/src/features/notifications/pages/NotificationsPage.tsx index c650a6203..b4b5fa23a 100644 --- a/frontend/src/features/notifications/pages/NotificationsPage.tsx +++ b/frontend/src/features/notifications/pages/NotificationsPage.tsx @@ -4,17 +4,17 @@ import { Bell, CheckCheck, ChevronLeft, ChevronRight, Loader2 } from 'lucide-rea import { Button } from '@/shared/components/ui/button' import { useNotifications } from '../services/notifications.context' import { notificationsHttpService } from '../services/notifications-http.service' -import { NotificationRow } from '../components/NotificationRow' -import type { Notification } from '../types/notification.types' +import { NotificationGroupRow } from '../components/NotificationGroupRow' +import type { NotificationGroup } from '../types/notification.types' const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] export function NotificationsPage() { const { t } = useTranslation() - const { markRead, remove, markAllRead, unreadCount, refreshUnread } = useNotifications() + const { markAllRead, unreadCount, refreshUnread } = useNotifications() const [page, setPage] = useState(0) const [pageSize, setPageSize] = useState(20) - const [items, setItems] = useState([]) + const [groups, setGroups] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) @@ -26,12 +26,12 @@ export function NotificationsPage() { setLoading(true) setError(false) try { - const { data, total } = await notificationsHttpService.listPaged({ + const { data, total } = await notificationsHttpService.listGrouped({ page: p, size: pageSize, status: 'ACTIVE', }) - setItems(data) + setGroups(data) setTotal(total) } catch { setError(true) @@ -51,36 +51,13 @@ export function NotificationsPage() { setPage(0) } - const onToggleRead = async (n: Notification) => { - setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: !n.read } : x))) - try { - await markRead(n.id, !n.read) - } catch { - setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: n.read } : x))) - } - } - - const onDelete = async (n: Notification) => { - // Optimistic: drop the row immediately so there's no dead time / reload flash. - const remaining = items.length - 1 - setItems((prev) => prev.filter((x) => x.id !== n.id)) - setTotal((t) => Math.max(0, t - 1)) - try { - await remove(n.id) - // If that emptied a non-first page, step back one (this reloads). - if (remaining === 0 && page > 0) setPage((p) => p - 1) - } catch { - // Failed — re-sync the page from the server. - void load(page) - } - } - const onMarkAll = async () => { - setItems((prev) => prev.map((x) => ({ ...x, read: true }))) + setGroups((prev) => prev.map((g) => ({ ...g, unreadCount: 0 }))) try { await markAllRead() } catch { void refreshUnread() + void load(page) } } @@ -99,7 +76,7 @@ export function NotificationsPage() { -
+
{loading && (
@@ -115,18 +92,18 @@ export function NotificationsPage() {
)} - {!loading && !error && items.length === 0 && ( + {!loading && !error && groups.length === 0 && (
{t('notifications.empty')}
)} - {items.map((n) => ( - void onToggleRead(x)} - onDelete={(x) => void onDelete(x)} + {groups.map((g) => ( + void refreshUnread()} + itemsMaxHeightClass="max-h-96" /> ))}
diff --git a/frontend/src/features/notifications/services/notifications-http.service.ts b/frontend/src/features/notifications/services/notifications-http.service.ts index e5d3954b7..b6409c080 100644 --- a/frontend/src/features/notifications/services/notifications-http.service.ts +++ b/frontend/src/features/notifications/services/notifications-http.service.ts @@ -1,6 +1,7 @@ import { createApiClient } from '@/shared/lib/api-client' import type { Notification, + NotificationGroup, NotificationListQuery, NotificationStatus, } from '../types/notification.types' @@ -15,6 +16,7 @@ function toQuery(q: NotificationListQuery): string { if (q.status) params.set('status', q.status) if (q.type) params.set('type', q.type) if (q.source) params.set('source', q.source) + if (q.message) params.set('message', q.message) if (q.from) params.set('from', q.from) if (q.to) params.set('to', q.to) if (q.sort) params.set('sort', q.sort) @@ -23,11 +25,14 @@ function toQuery(q: NotificationListQuery): string { } export const notificationsHttpService = { - /** List (bare array; newest first). Used by the infinite-scroll bell dropdown. */ + /** List (bare array; newest first). Used inside an expanded group. */ list: (q: NotificationListQuery = {}) => api.get(`/notifications${toQuery(q)}`), /** Same list but with the total count (X-Total-Count) for classic pagination. */ listPaged: (q: NotificationListQuery = {}) => api.getPaged(`/notifications${toQuery(q)}`), + /** Groups (source/type/message + counts). Powers the grouped page + bell view. */ + listGrouped: (q: NotificationListQuery = {}) => + api.getPaged(`/notifications/grouped${toQuery(q)}`), /** Count of unread active notifications (for the bell badge). */ unreadCount: () => api.get('/notifications/unread-count'), getById: (id: number) => api.get(`/notifications/${id}`), diff --git a/frontend/src/features/notifications/types/notification.types.ts b/frontend/src/features/notifications/types/notification.types.ts index 471882cf2..ad7862408 100644 --- a/frontend/src/features/notifications/types/notification.types.ts +++ b/frontend/src/features/notifications/types/notification.types.ts @@ -20,8 +20,20 @@ export interface NotificationListQuery { status?: NotificationStatus type?: NotificationType source?: string + /** Exact message (used to pull the notifications of a specific group). */ + message?: string from?: string to?: string /** "field,asc|desc"; backend defaults to created_at desc. */ sort?: string } + +/** One row of the /notifications/grouped response — a stack of same source/type/message. */ +export interface NotificationGroup { + source: string + type: NotificationType + message: string + count: number + unreadCount: number + lastCreated: string +}