diff --git a/internal/handler/notification_handler.go b/internal/handler/notification_handler.go new file mode 100644 index 0000000..d12fd94 --- /dev/null +++ b/internal/handler/notification_handler.go @@ -0,0 +1,158 @@ +package handler + +import ( + "accounting-app/internal/models" + "accounting-app/internal/service" + "accounting-app/pkg/api" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type NotificationHandler struct { + service *service.NotificationService +} + +func NewNotificationHandler(service *service.NotificationService) *NotificationHandler { + return &NotificationHandler{service: service} +} + +// GetNotifications returns a list of notifications for the user +func (h *NotificationHandler) GetNotifications(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + + var isRead *bool + if val, ok := c.GetQuery("is_read"); ok { + parsedVal, err := strconv.ParseBool(val) + if err == nil { + isRead = &parsedVal + } + } + + notifications, total, err := h.service.GetNotifications(userID.(uint), page, limit, isRead) + if err != nil { + api.Error(c, http.StatusInternalServerError, "FETCH_ERROR", "Failed to fetch notifications") + return + } + + api.Success(c, gin.H{ + "notifications": notifications, + "total": total, + "page": page, + "limit": limit, + }) +} + +// GetUnreadCount returns the number of unread notifications +func (h *NotificationHandler) GetUnreadCount(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + count, err := h.service.GetUnreadCount(userID.(uint)) + if err != nil { + api.Error(c, http.StatusInternalServerError, "FETCH_ERROR", "Failed to count unread notifications") + return + } + + api.Success(c, gin.H{ + "count": count, + }) +} + +// MarkAsRead marks a specific notification as read +func (h *NotificationHandler) MarkAsRead(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + api.Error(c, http.StatusBadRequest, "INVALID_ID", "Invalid notification ID") + return + } + + if err := h.service.MarkAsRead(uint(id), userID.(uint)); err != nil { + api.Error(c, http.StatusInternalServerError, "UPDATE_ERROR", "Failed to mark as read") + return + } + + api.Success(c, gin.H{"message": "Marked as read"}) +} + +// MarkAllAsRead marks all notifications as read +func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + if err := h.service.MarkAllAsRead(userID.(uint)); err != nil { + api.Error(c, http.StatusInternalServerError, "UPDATE_ERROR", "Failed to mark all as read") + return + } + + api.Success(c, gin.H{"message": "All marked as read"}) +} + +// CreateNotificationRequest represents the request body for creating a notification (internal/admin use primarily) +type CreateNotificationRequest struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Type string `json:"type" binding:"required"` + Link string `json:"link"` +} + +// CreateNotification creates a new notification (For testing/admin purposes) +func (h *NotificationHandler) CreateNotification(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + var req CreateNotificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "Invalid request") + return + } + + err := h.service.CreateNotification( + userID.(uint), + req.Title, + req.Content, + models.NotificationType(req.Type), + req.Link, + ) + if err != nil { + api.Error(c, http.StatusInternalServerError, "CREATE_ERROR", "Failed to create notification") + return + } + + api.Success(c, gin.H{"message": "Notification created"}) +} + +// RegisterRoutes registers the notification routes +func (h *NotificationHandler) RegisterRoutes(r *gin.RouterGroup) { + notifications := r.Group("/notifications") + { + notifications.GET("", h.GetNotifications) + notifications.GET("/unread-count", h.GetUnreadCount) + notifications.PUT("/:id/read", h.MarkAsRead) + notifications.PUT("/read-all", h.MarkAllAsRead) + notifications.POST("", h.CreateNotification) // Admin/Internal use + } +} diff --git a/internal/models/notification.go b/internal/models/notification.go new file mode 100644 index 0000000..895244c --- /dev/null +++ b/internal/models/notification.go @@ -0,0 +1,34 @@ +package models + +import ( + "time" +) + +// NotificationType represents the type of notification +type NotificationType string + +const ( + NotificationTypeSystem NotificationType = "system" + NotificationTypeAlert NotificationType = "alert" + NotificationTypeInfo NotificationType = "info" +) + +// Notification represents a system notification for a user +type Notification struct { + BaseModel + UserID uint `gorm:"not null;index" json:"user_id"` + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + Type NotificationType `gorm:"size:20;not null;default:'system'" json:"type"` + IsRead bool `gorm:"default:false" json:"is_read"` + Link string `gorm:"size:255" json:"link,omitempty"` // Optional link to redirect + ReadAt *time.Time `json:"read_at,omitempty"` + + // Relationships + User User `gorm:"foreignKey:UserID" json:"-"` +} + +// TableName specifies the table name for Notification +func (Notification) TableName() string { + return "notifications" +} diff --git a/internal/repository/notification_repository.go b/internal/repository/notification_repository.go new file mode 100644 index 0000000..cfd798a --- /dev/null +++ b/internal/repository/notification_repository.go @@ -0,0 +1,74 @@ +package repository + +import ( + "accounting-app/internal/models" + "time" + + "gorm.io/gorm" +) + +type NotificationRepository struct { + db *gorm.DB +} + +func NewNotificationRepository(db *gorm.DB) *NotificationRepository { + return &NotificationRepository{db: db} +} + +// Create creates a new notification +func (r *NotificationRepository) Create(notification *models.Notification) error { + return r.db.Create(notification).Error +} + +// FindAll retrieves notifications for a user with pagination and filtering +func (r *NotificationRepository) FindAll(userID uint, page, pageSize int, isRead *bool) ([]models.Notification, int64, error) { + var notifications []models.Notification + var total int64 + + query := r.db.Model(&models.Notification{}).Where("user_id = ?", userID) + + if isRead != nil { + query = query.Where("is_read = ?", *isRead) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + err := query.Order("created_at desc"). + Offset(offset). + Limit(pageSize). + Find(¬ifications).Error + + return notifications, total, err +} + +// CountUnread counts unread notifications for a user +func (r *NotificationRepository) CountUnread(userID uint) (int64, error) { + var count int64 + err := r.db.Model(&models.Notification{}). + Where("user_id = ? AND is_read = ?", userID, false). + Count(&count).Error + return count, err +} + +// MarkAsRead marks a single notification as read +func (r *NotificationRepository) MarkAsRead(id uint, userID uint) error { + return r.db.Model(&models.Notification{}). + Where("id = ? AND user_id = ?", id, userID). + Updates(map[string]interface{}{ + "is_read": true, + "read_at": time.Now(), + }).Error +} + +// MarkAllAsRead marks all notifications as read for a user +func (r *NotificationRepository) MarkAllAsRead(userID uint) error { + return r.db.Model(&models.Notification{}). + Where("user_id = ? AND is_read = ?", userID, false). + Updates(map[string]interface{}{ + "is_read": true, + "read_at": time.Now(), + }).Error +} diff --git a/internal/router/router.go b/internal/router/router.go index 9b95945..7a63283 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -49,6 +49,7 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) ledgerRepo := repository.NewLedgerRepository(db) userSettingsRepo := repository.NewUserSettingsRepository(db) userRepo := repository.NewUserRepository(db) + notificationRepo := repository.NewNotificationRepository(db) // Initialize auth services authService := service.NewAuthService(userRepo, cfg) @@ -83,6 +84,7 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) reimbursementService := service.NewReimbursementService(db, transactionRepo, accountRepo) refundService := service.NewRefundService(db, transactionRepo, accountRepo) userSettingsService := service.NewUserSettingsService(userSettingsRepo) + notificationService := service.NewNotificationService(notificationRepo) // Feature: financial-core-upgrade - Initialize new services subAccountService := service.NewSubAccountService(accountRepo, db) @@ -112,6 +114,7 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) reimbursementHandler := handler.NewReimbursementHandler(reimbursementService) refundHandler := handler.NewRefundHandler(refundService) settingsHandler := handler.NewSettingsHandler(userSettingsService) + notificationHandler := handler.NewNotificationHandler(notificationService) // Feature: financial-core-upgrade - Initialize new handlers subAccountHandler := handler.NewSubAccountHandler(subAccountService) @@ -237,6 +240,9 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) protected.POST("/app-lock/verify", appLockHandler.VerifyPassword) protected.POST("/app-lock/disable", appLockHandler.DisableLock) protected.POST("/app-lock/password/change", appLockHandler.ChangePassword) + + // Register notification routes + notificationHandler.RegisterRoutes(protected) } } @@ -297,6 +303,7 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient ledgerRepo := repository.NewLedgerRepository(db) userSettingsRepo := repository.NewUserSettingsRepository(db) userRepo := repository.NewUserRepository(db) + notificationRepo := repository.NewNotificationRepository(db) // Initialize auth services authService := service.NewAuthService(userRepo, cfg) @@ -330,6 +337,7 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient reimbursementService := service.NewReimbursementService(db, transactionRepo, accountRepo) refundService := service.NewRefundService(db, transactionRepo, accountRepo) userSettingsService := service.NewUserSettingsService(userSettingsRepo) + notificationService := service.NewNotificationService(notificationRepo) // Feature: financial-core-upgrade - Initialize new services subAccountService := service.NewSubAccountService(accountRepo, db) @@ -365,6 +373,7 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient reimbursementHandler := handler.NewReimbursementHandler(reimbursementService) refundHandler := handler.NewRefundHandler(refundService) settingsHandler := handler.NewSettingsHandler(userSettingsService) + notificationHandler := handler.NewNotificationHandler(notificationService) // Feature: financial-core-upgrade - Initialize new handlers subAccountHandler := handler.NewSubAccountHandler(subAccountService) @@ -500,6 +509,9 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient v1.POST("/app-lock/verify", appLockHandler.VerifyPassword) v1.POST("/app-lock/disable", appLockHandler.DisableLock) v1.POST("/app-lock/password/change", appLockHandler.ChangePassword) + + // Register notification routes + notificationHandler.RegisterRoutes(protected) } return r, syncScheduler diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go new file mode 100644 index 0000000..cd809d1 --- /dev/null +++ b/internal/service/notification_service.go @@ -0,0 +1,47 @@ +package service + +import ( + "accounting-app/internal/models" + "accounting-app/internal/repository" +) + +type NotificationService struct { + repo *repository.NotificationRepository +} + +func NewNotificationService(repo *repository.NotificationRepository) *NotificationService { + return &NotificationService{repo: repo} +} + +func (s *NotificationService) CreateNotification(userID uint, title, content string, notifType models.NotificationType, link string) error { + notification := &models.Notification{ + UserID: userID, + Title: title, + Content: content, + Type: notifType, + Link: link, + } + return s.repo.Create(notification) +} + +func (s *NotificationService) GetNotifications(userID uint, page, limit int, isRead *bool) ([]models.Notification, int64, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 10 + } + return s.repo.FindAll(userID, page, limit, isRead) +} + +func (s *NotificationService) GetUnreadCount(userID uint) (int64, error) { + return s.repo.CountUnread(userID) +} + +func (s *NotificationService) MarkAsRead(id uint, userID uint) error { + return s.repo.MarkAsRead(id, userID) +} + +func (s *NotificationService) MarkAllAsRead(userID uint) error { + return s.repo.MarkAllAsRead(userID) +}