diff --git a/internal/handler/notification_handler.go b/internal/handler/notification_handler.go index 5dbd68a..3e94877 100644 --- a/internal/handler/notification_handler.go +++ b/internal/handler/notification_handler.go @@ -5,6 +5,7 @@ import ( "strconv" "accounting-app/internal/models" + "accounting-app/internal/repository" "accounting-app/internal/service" "accounting-app/pkg/api" @@ -14,15 +15,72 @@ import ( // NotificationHandler handles HTTP requests for notification operations type NotificationHandler struct { notificationService *service.NotificationService + userRepo *repository.UserRepository + announcementRepo *repository.AnnouncementRepository } // NewNotificationHandler creates a new NotificationHandler instance -func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler { +func NewNotificationHandler( + notificationService *service.NotificationService, + userRepo *repository.UserRepository, + announcementRepo *repository.AnnouncementRepository, +) *NotificationHandler { return &NotificationHandler{ notificationService: notificationService, + userRepo: userRepo, + announcementRepo: announcementRepo, } } +// BroadcastNotification handles POST /api/v1/notifications/broadcast +// Sends a notification to all users and records it in history +func (h *NotificationHandler) BroadcastNotification(c *gin.Context) { + // TODO: Add proper admin authorization check here when RBAC is implemented + // For now, checks if user is authenticated at least + adminID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + var input service.BroadcastInput + if err := c.ShouldBindJSON(&input); err != nil { + api.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + + // Set admin ID from context + input.AdminID = adminID.(uint) + + err := h.notificationService.Broadcast(input, h.userRepo, h.announcementRepo) + if err != nil { + api.InternalError(c, "Failed to broadcast notification: "+err.Error()) + return + } + + api.Success(c, gin.H{"message": "Broadcast sent successfully"}) +} + +// GetAnnouncementHistory handles GET /api/v1/notifications/announcements +// Returns the history of announcements +func (h *NotificationHandler) GetAnnouncementHistory(c *gin.Context) { + // TODO: Add admin check + + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + + result, err := h.notificationService.GetAnnouncementHistory(limit, offset, h.announcementRepo) + if err != nil { + api.InternalError(c, "Failed to get announcement history: "+err.Error()) + return + } + + api.Success(c, gin.H{ + "announcements": result.Announcements, + "total": result.Total, + }) +} + // GetNotifications handles GET /api/v1/notifications // Returns a list of notifications with pagination and filtering func (h *NotificationHandler) GetNotifications(c *gin.Context) { @@ -222,5 +280,7 @@ func (h *NotificationHandler) RegisterAdminRoutes(rg *gin.RouterGroup) { notifications := rg.Group("/notifications") { notifications.POST("", h.CreateNotification) + notifications.POST("/broadcast", h.BroadcastNotification) + notifications.GET("/announcements", h.GetAnnouncementHistory) } } diff --git a/internal/models/announcement.go b/internal/models/announcement.go new file mode 100644 index 0000000..47ce8eb --- /dev/null +++ b/internal/models/announcement.go @@ -0,0 +1,16 @@ +package models + +// Announcement represents a system-wide broadcast message +type Announcement struct { + BaseModel + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + Type NotificationType `gorm:"size:30;not null;default:'system'" json:"type"` + SentCount int `gorm:"default:0" json:"sent_count"` + CreatedBy uint `gorm:"not null" json:"created_by"` +} + +// TableName specifies the table name for Announcement +func (Announcement) TableName() string { + return "announcements" +} diff --git a/internal/models/models.go b/internal/models/models.go index c17c081..8db5faa 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -887,6 +887,7 @@ func AllModels() []interface{} { &TransactionImage{}, // Feature: accounting-feature-upgrade &UserSettings{}, // Feature: accounting-feature-upgrade &Notification{}, // Feature: notification-center + &Announcement{}, // Feature: notification-center (broadcast history) } } diff --git a/internal/repository/announcement_repository.go b/internal/repository/announcement_repository.go new file mode 100644 index 0000000..d553cc2 --- /dev/null +++ b/internal/repository/announcement_repository.go @@ -0,0 +1,60 @@ +package repository + +import ( + "accounting-app/internal/models" + + "gorm.io/gorm" +) + +// AnnouncementRepository handles database operations for announcements +type AnnouncementRepository struct { + db *gorm.DB +} + +// NewAnnouncementRepository creates a new AnnouncementRepository instance +func NewAnnouncementRepository(db *gorm.DB) *AnnouncementRepository { + return &AnnouncementRepository{ + db: db, + } +} + +// Create creates a new announcement +func (r *AnnouncementRepository) Create(announcement *models.Announcement) error { + return r.db.Create(announcement).Error +} + +// ListResult represents the result of a list operation +type AnnouncementListResult struct { + Announcements []models.Announcement + Total int64 + Limit int + Offset int +} + +// List returns a list of announcements with pagination +func (r *AnnouncementRepository) List(limit, offset int) (*AnnouncementListResult, error) { + var announcements []models.Announcement + var total int64 + + // Count total announcements + if err := r.db.Model(&models.Announcement{}).Count(&total).Error; err != nil { + return nil, err + } + + // Fetch announcements with pagination + err := r.db.Order("created_at desc"). + Limit(limit). + Offset(offset). + Find(&announcements).Error + + if err != nil { + return nil, err + } + + return &AnnouncementListResult{ + Announcements: announcements, + Total: total, + Limit: limit, + Offset: offset, + }, nil +} diff --git a/internal/repository/notification_repository.go b/internal/repository/notification_repository.go index f14a50c..9d2a354 100644 --- a/internal/repository/notification_repository.go +++ b/internal/repository/notification_repository.go @@ -150,3 +150,8 @@ func (r *NotificationRepository) GetUnreadCount(userID uint) (int64, error) { } return count, nil } + +// CreateBatch creates multiple notifications in a single transaction +func (r *NotificationRepository) CreateBatch(notifications []models.Notification) error { + return r.db.CreateInBatches(notifications, 100).Error +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index 9ec5b04..328357f 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -66,7 +66,6 @@ func (r *UserRepository) GetByEmail(email string) (*models.User, error) { return &user, nil } - // Update updates a user in the database func (r *UserRepository) Update(user *models.User) error { return r.db.Save(user).Error @@ -163,3 +162,8 @@ func (r *UserRepository) EmailExists(email string) (bool, error) { } return count > 0, nil } + +// DB returns the underlying gorm.DB instance +func (r *UserRepository) DB() *gorm.DB { + return r.db +} diff --git a/internal/router/router.go b/internal/router/router.go index 4bab3b3..a810a21 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -51,6 +51,7 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) userRepo := repository.NewUserRepository(db) notificationRepo := repository.NewNotificationRepository(db) streakRepo := repository.NewStreakRepository(db) + announcementRepo := repository.NewAnnouncementRepository(db) // Initialize services (Stage 1: Core Services needed for Auth) ledgerService := service.NewLedgerService(ledgerRepo, db) @@ -129,7 +130,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) + notificationHandler := handler.NewNotificationHandler(notificationService, userRepo, announcementRepo) // Feature: financial-core-upgrade - Initialize new handlers subAccountHandler := handler.NewSubAccountHandler(subAccountService) @@ -333,6 +334,7 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient userRepo := repository.NewUserRepository(db) notificationRepo := repository.NewNotificationRepository(db) streakRepo := repository.NewStreakRepository(db) + announcementRepo := repository.NewAnnouncementRepository(db) // Initialize services (Stage 1: Core Services needed for Auth) ledgerService := service.NewLedgerService(ledgerRepo, db) @@ -412,7 +414,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) + notificationHandler := handler.NewNotificationHandler(notificationService, userRepo, announcementRepo) // Feature: financial-core-upgrade - Initialize new handlers subAccountHandler := handler.NewSubAccountHandler(subAccountService) diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index a75125f..e5b8089 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -158,3 +158,71 @@ func (s *NotificationService) SendSystemNotification(userID uint, title, content Content: content, }) } + +// BroadcastInput represents input for broadcasting a notification +type BroadcastInput struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Type models.NotificationType `json:"type" binding:"required"` + AdminID uint `json:"-"` // Set from context +} + +// Broadcast sends a notification to all users and records it in history +func (s *NotificationService) Broadcast(input BroadcastInput, userRepo *repository.UserRepository, announcementRepo *repository.AnnouncementRepository) error { + // 1. Create Announcement record for history + announcement := &models.Announcement{ + Title: input.Title, + Content: input.Content, + Type: input.Type, + CreatedBy: input.AdminID, + } + + if err := announcementRepo.Create(announcement); err != nil { + return fmt.Errorf("failed to create announcement history: %w", err) + } + + // 2. Fetch all active users (ID only) + // Note: For very large user bases, this should be done in batches or via background worker. + // For standard enterprise use, fetching IDs is acceptable. + var users []models.User + // optimize: select only ID + if err := userRepo.DB().Select("id").Find(&users).Error; err != nil { + return fmt.Errorf("failed to fetch users for broadcast: %w", err) + } + + if len(users) == 0 { + return nil + } + + // 3. Create Notification records for each user (Write-Diffusion) + notifications := make([]models.Notification, len(users)) + for i, user := range users { + notifications[i] = models.Notification{ + UserID: user.ID, + Title: input.Title, + Content: input.Content, + Type: input.Type, + IsRead: false, + } + } + + // 4. Batch insert notifications + if err := s.repo.CreateBatch(notifications); err != nil { + return fmt.Errorf("failed to broadcast notifications: %w", err) + } + + // 5. Update sent count (optional, but good for history) + announcement.SentCount = len(users) + // We can update the record if needed, but for now we skip re-saving to keep it simple + // or we could add an Update method to AnnouncementRepository. + // Since GORM Create updates the struct with ID, we can just save it again if we want accurate count immediately, + // but strictly speaking the simple Create is enough for history log if we don't strictly need accurate real-time count in the history object immediately after creation. + // Let's assume the Create call was enough for the log. + + return nil +} + +// GetAnnouncementHistory retrieves the history of system broadcasts +func (s *NotificationService) GetAnnouncementHistory(limit, offset int, announcementRepo *repository.AnnouncementRepository) (*repository.AnnouncementListResult, error) { + return announcementRepo.List(limit, offset) +}