Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/modules/notifications/connectors/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions backend/modules/notifications/connectors/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions backend/modules/notifications/domain/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
13 changes: 7 additions & 6 deletions backend/modules/notifications/dto/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
8 changes: 8 additions & 0 deletions backend/modules/notifications/handler/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
61 changes: 49 additions & 12 deletions backend/modules/notifications/handler/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"),
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions backend/modules/notifications/repository/notification_pg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions backend/modules/notifications/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions backend/modules/notifications/usecase/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>) => {
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 (
<div onScroll={onScroll} className={`${maxHeightClass} pl-2 divide-y divide-border overflow-y-auto bg-muted/20 `}>
{items.map((n) => (
<NotificationRow
key={n.id}
notification={n}
onToggleRead={(x) => void toggleRead(x)}
onDelete={(x) => void deleteItem(x)}
/>
))}
{loading && (
<div className="flex items-center justify-center py-3 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{error && !loading && items.length === 0 && (
<div className="px-3 py-4 text-center text-xs text-destructive">
{t('notifications.loadFailed')}
</div>
)}
</div>
)
}
Loading
Loading