package handlers import ( "fmt" "net/http" "sort" "strconv" "strings" "time" "game-admin/internal/models" "github.com/gin-gonic/gin" "github.com/uptrace/bun" ) var defaultNotificationMacroKeys = []string{"time_second", "image", "login"} func (h *GameHandler) ListNotifications(c *gin.Context) { gameID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"}) return } if err := h.ensureNotificationModuleEnabled(c, gameID); err != nil { renderNotificationModuleError(c, err) return } var notifications []models.Notification err = h.db.NewSelect(). Model(¬ifications). Relation("Descriptions", func(q *bun.SelectQuery) *bun.SelectQuery { return q.Relation("Language").OrderExpr("nd.id ASC") }). Relation("Variables", func(q *bun.SelectQuery) *bun.SelectQuery { return q.OrderExpr("nvd.id ASC") }). Relation("Entries", func(q *bun.SelectQuery) *bun.SelectQuery { return q.OrderExpr("ne.id ASC") }). Where("n.game_id = ?", gameID). OrderExpr("n.id DESC"). Scan(c) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := h.loadNotificationEntryVariables(c, notifications); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } for i := range notifications { prepareNotificationResponse(¬ifications[i]) } c.JSON(http.StatusOK, notifications) } func (h *GameHandler) GetNotification(c *gin.Context) { gameID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"}) return } notificationID, err := strconv.ParseInt(c.Param("notification_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification id"}) return } if err := h.ensureNotificationModuleEnabled(c, gameID); err != nil { renderNotificationModuleError(c, err) return } notification, err := h.getNotification(c, gameID, notificationID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"}) return } c.JSON(http.StatusOK, notification) } func (h *GameHandler) CreateNotification(c *gin.Context) { gameID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"}) return } var req models.UpsertNotificationRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := validateUpsertNotificationRequest(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.ensureNotificationModuleEnabled(c, gameID); err != nil { renderNotificationModuleError(c, err) return } if err := h.ensureLanguagesExist(c, req.Descriptions); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tx, err := h.db.BeginTx(c, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } committed := false defer func() { if !committed { _ = tx.Rollback() } }() now := time.Now() notification := &models.Notification{ GameID: gameID, Name: strings.TrimSpace(req.Name), CreatedAt: now, UpdatedAt: now, } _, err = tx.NewInsert().Model(notification).Exec(c) if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "Notification already exists for this game"}) return } if err := saveNotificationDetails(c, tx, notification.ID, req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := tx.Commit(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } committed = true created, err := h.getNotification(c, gameID, notification.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, created) } func (h *GameHandler) UpdateNotification(c *gin.Context) { gameID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"}) return } notificationID, err := strconv.ParseInt(c.Param("notification_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification id"}) return } var req models.UpsertNotificationRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := validateUpsertNotificationRequest(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.ensureNotificationModuleEnabled(c, gameID); err != nil { renderNotificationModuleError(c, err) return } if err := h.ensureLanguagesExist(c, req.Descriptions); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if _, err := h.getNotification(c, gameID, notificationID); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"}) return } tx, err := h.db.BeginTx(c, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } committed := false defer func() { if !committed { _ = tx.Rollback() } }() _, err = tx.NewUpdate(). Model((*models.Notification)(nil)). Set("name = ?", strings.TrimSpace(req.Name)). Set("updated_at = ?", time.Now()). Where("id = ? AND game_id = ?", notificationID, gameID). Exec(c) if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "Could not update notification"}) return } if err := deleteNotificationDetails(c, tx, notificationID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := saveNotificationDetails(c, tx, notificationID, req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := tx.Commit(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } committed = true updated, err := h.getNotification(c, gameID, notificationID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, updated) } func (h *GameHandler) DeleteNotification(c *gin.Context) { gameID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"}) return } notificationID, err := strconv.ParseInt(c.Param("notification_id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification id"}) return } if err := h.ensureNotificationModuleEnabled(c, gameID); err != nil { renderNotificationModuleError(c, err) return } if _, err := h.getNotification(c, gameID, notificationID); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"}) return } tx, err := h.db.BeginTx(c, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } committed := false defer func() { if !committed { _ = tx.Rollback() } }() if err := deleteNotificationDetails(c, tx, notificationID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } _, err = tx.NewDelete(). Model((*models.Notification)(nil)). Where("id = ? AND game_id = ?", notificationID, gameID). Exec(c) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := tx.Commit(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } committed = true c.JSON(http.StatusOK, gin.H{"message": "Notification deleted"}) } func (h *GameHandler) ensureNotificationModuleEnabled(c *gin.Context, gameID int64) error { exists, err := h.gameExists(c, gameID) if err != nil { return err } if !exists { return fmt.Errorf("game_not_found") } hasModule, err := h.gameHasModule(c, gameID, "notification") if err != nil { return err } if !hasModule { return fmt.Errorf("notification_module_disabled") } return nil } func renderNotificationModuleError(c *gin.Context, err error) { switch err.Error() { case "game_not_found": c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"}) case "notification_module_disabled": c.JSON(http.StatusForbidden, gin.H{"error": "Notification module is not enabled for this game"}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } } func (h *GameHandler) ensureLanguagesExist(c *gin.Context, descriptions []models.NotificationDescriptionRequest) error { languageIDs := make([]int64, 0, len(descriptions)) seen := make(map[int64]struct{}, len(descriptions)) for _, description := range descriptions { if _, ok := seen[description.LanguageID]; ok { continue } seen[description.LanguageID] = struct{}{} languageIDs = append(languageIDs, description.LanguageID) } var languages []models.Language err := h.db.NewSelect(). Model(&languages). Where("id IN (?)", bun.In(languageIDs)). Scan(c) if err != nil { return err } if len(languages) != len(languageIDs) { return fmt.Errorf("one or more languages do not exist") } return nil } func (h *GameHandler) getNotification(c *gin.Context, gameID, notificationID int64) (*models.Notification, error) { notification := new(models.Notification) err := h.db.NewSelect(). Model(notification). Relation("Descriptions", func(q *bun.SelectQuery) *bun.SelectQuery { return q.Relation("Language").OrderExpr("nd.id ASC") }). Relation("Variables", func(q *bun.SelectQuery) *bun.SelectQuery { return q.OrderExpr("nvd.id ASC") }). Relation("Entries", func(q *bun.SelectQuery) *bun.SelectQuery { return q.OrderExpr("ne.id ASC") }). Where("n.id = ? AND n.game_id = ?", notificationID, gameID). Scan(c) if err != nil { return nil, err } if err := h.loadNotificationEntryVariablesForOne(c, notification); err != nil { return nil, err } prepareNotificationResponse(notification) return notification, nil } func (h *GameHandler) loadNotificationEntryVariables(c *gin.Context, notifications []models.Notification) error { entryIDs := make([]int64, 0) for i := range notifications { for j := range notifications[i].Entries { entryIDs = append(entryIDs, notifications[i].Entries[j].ID) } } if len(entryIDs) == 0 { return nil } var entryVariables []models.NotificationEntryVariable err := h.db.NewSelect(). Model(&entryVariables). Relation("Variable"). Where("nev.notification_entry_id IN (?)", bun.In(entryIDs)). OrderExpr("nev.id ASC"). Scan(c) if err != nil { return err } variablesByEntry := make(map[int64][]models.NotificationEntryVariable, len(entryIDs)) for _, item := range entryVariables { if item.Variable != nil { item.Key = item.Variable.Key } variablesByEntry[item.NotificationEntryID] = append(variablesByEntry[item.NotificationEntryID], item) } for i := range notifications { for j := range notifications[i].Entries { notifications[i].Entries[j].Variables = variablesByEntry[notifications[i].Entries[j].ID] } } return nil } func (h *GameHandler) loadNotificationEntryVariablesForOne(c *gin.Context, notification *models.Notification) error { entryIDs := make([]int64, 0, len(notification.Entries)) for i := range notification.Entries { entryIDs = append(entryIDs, notification.Entries[i].ID) } if len(entryIDs) == 0 { return nil } var entryVariables []models.NotificationEntryVariable err := h.db.NewSelect(). Model(&entryVariables). Relation("Variable"). Where("nev.notification_entry_id IN (?)", bun.In(entryIDs)). OrderExpr("nev.id ASC"). Scan(c) if err != nil { return err } variablesByEntry := make(map[int64][]models.NotificationEntryVariable, len(entryIDs)) for _, item := range entryVariables { if item.Variable != nil { item.Key = item.Variable.Key } variablesByEntry[item.NotificationEntryID] = append(variablesByEntry[item.NotificationEntryID], item) } for i := range notification.Entries { notification.Entries[i].Variables = variablesByEntry[notification.Entries[i].ID] } return nil } func deleteNotificationDetails(c *gin.Context, tx bun.Tx, notificationID int64) error { var entries []models.NotificationEntry if err := tx.NewSelect(). Model(&entries). Where("notification_id = ?", notificationID). Scan(c); err != nil { return err } if len(entries) > 0 { entryIDs := make([]int64, 0, len(entries)) for _, entry := range entries { entryIDs = append(entryIDs, entry.ID) } if _, err := tx.NewDelete(). Model((*models.NotificationEntryVariable)(nil)). Where("notification_entry_id IN (?)", bun.In(entryIDs)). Exec(c); err != nil { return err } } if _, err := tx.NewDelete(). Model((*models.NotificationEntry)(nil)). Where("notification_id = ?", notificationID). Exec(c); err != nil { return err } if _, err := tx.NewDelete(). Model((*models.NotificationVariableDef)(nil)). Where("notification_id = ?", notificationID). Exec(c); err != nil { return err } if _, err := tx.NewDelete(). Model((*models.NotificationDescription)(nil)). Where("notification_id = ?", notificationID). Exec(c); err != nil { return err } return nil } func saveNotificationDetails(c *gin.Context, tx bun.Tx, notificationID int64, req models.UpsertNotificationRequest) error { now := time.Now() for _, item := range req.Descriptions { description := &models.NotificationDescription{ NotificationID: notificationID, LanguageID: item.LanguageID, Description: strings.TrimSpace(item.Description), CreatedAt: now, UpdatedAt: now, } if _, err := tx.NewInsert().Model(description).Exec(c); err != nil { return fmt.Errorf("could not save notification descriptions") } } variableIDs := make(map[string]int64, len(req.Variables)) for _, item := range req.Variables { variable := &models.NotificationVariableDef{ NotificationID: notificationID, Key: strings.TrimSpace(item.Key), CreatedAt: now, UpdatedAt: now, } if _, err := tx.NewInsert().Model(variable).Exec(c); err != nil { return fmt.Errorf("could not save notification variable definitions") } variableIDs[variable.Key] = variable.ID } for _, entryReq := range req.Entries { entry := &models.NotificationEntry{ NotificationID: notificationID, TimeSecond: entryReq.TimeSecond, Image: strings.TrimSpace(entryReq.Image), Login: strings.TrimSpace(entryReq.Login), CreatedAt: now, UpdatedAt: now, } if _, err := tx.NewInsert().Model(entry).Exec(c); err != nil { return fmt.Errorf("could not save notification entries") } for _, variableReq := range entryReq.Variables { entryVariable := &models.NotificationEntryVariable{ NotificationEntryID: entry.ID, NotificationVariableID: variableIDs[strings.TrimSpace(variableReq.Key)], Value: strings.TrimSpace(variableReq.Value), CreatedAt: now, UpdatedAt: now, } if _, err := tx.NewInsert().Model(entryVariable).Exec(c); err != nil { return fmt.Errorf("could not save notification entry variables") } } } return nil } func validateUpsertNotificationRequest(req *models.UpsertNotificationRequest) error { req.Name = strings.TrimSpace(req.Name) if req.Name == "" { return fmt.Errorf("name is required") } if len(req.Descriptions) == 0 { return fmt.Errorf("at least one description is required") } if len(req.Entries) == 0 { return fmt.Errorf("at least one notification entry is required") } seenLanguages := make(map[int64]struct{}, len(req.Descriptions)) for i := range req.Descriptions { req.Descriptions[i].Description = strings.TrimSpace(req.Descriptions[i].Description) if req.Descriptions[i].LanguageID <= 0 { return fmt.Errorf("descriptions[%d].language_id is required", i) } if req.Descriptions[i].Description == "" { return fmt.Errorf("descriptions[%d].description is required", i) } if _, exists := seenLanguages[req.Descriptions[i].LanguageID]; exists { return fmt.Errorf("duplicate description language_id: %d", req.Descriptions[i].LanguageID) } seenLanguages[req.Descriptions[i].LanguageID] = struct{}{} } variableKeys := make([]string, 0, len(req.Variables)) expectedKeys := make(map[string]struct{}, len(req.Variables)) for i := range req.Variables { req.Variables[i].Key = strings.TrimSpace(req.Variables[i].Key) if req.Variables[i].Key == "" { return fmt.Errorf("variables[%d].key is required", i) } for _, reserved := range defaultNotificationMacroKeys { if req.Variables[i].Key == reserved { return fmt.Errorf("variables[%d].key uses reserved macro name: %s", i, reserved) } } if _, exists := expectedKeys[req.Variables[i].Key]; exists { return fmt.Errorf("duplicate variable key: %s", req.Variables[i].Key) } expectedKeys[req.Variables[i].Key] = struct{}{} variableKeys = append(variableKeys, req.Variables[i].Key) } for i := range req.Entries { req.Entries[i].Image = strings.TrimSpace(req.Entries[i].Image) req.Entries[i].Login = strings.TrimSpace(req.Entries[i].Login) if req.Entries[i].TimeSecond < 0 { return fmt.Errorf("entries[%d].time_second must be >= 0", i) } if req.Entries[i].Image == "" { return fmt.Errorf("entries[%d].image is required", i) } if req.Entries[i].Login == "" { return fmt.Errorf("entries[%d].login is required", i) } entrySeen := make(map[string]struct{}, len(req.Entries[i].Variables)) for j := range req.Entries[i].Variables { req.Entries[i].Variables[j].Key = strings.TrimSpace(req.Entries[i].Variables[j].Key) req.Entries[i].Variables[j].Value = strings.TrimSpace(req.Entries[i].Variables[j].Value) if req.Entries[i].Variables[j].Key == "" { return fmt.Errorf("entries[%d].variables[%d].key is required", i, j) } if _, exists := expectedKeys[req.Entries[i].Variables[j].Key]; !exists { return fmt.Errorf("entries[%d].variables[%d].key is not declared in notification variables", i, j) } if _, exists := entrySeen[req.Entries[i].Variables[j].Key]; exists { return fmt.Errorf("entries[%d] has duplicate variable key: %s", i, req.Entries[i].Variables[j].Key) } entrySeen[req.Entries[i].Variables[j].Key] = struct{}{} } if len(entrySeen) != len(expectedKeys) { missing := make([]string, 0, len(expectedKeys)-len(entrySeen)) for _, key := range variableKeys { if _, exists := entrySeen[key]; !exists { missing = append(missing, key) } } if len(missing) > 0 { return fmt.Errorf("entries[%d] is missing variable values for: %s", i, strings.Join(missing, ", ")) } } } return nil } func prepareNotificationResponse(notification *models.Notification) { sort.SliceStable(notification.Descriptions, func(i, j int) bool { return notification.Descriptions[i].LanguageID < notification.Descriptions[j].LanguageID }) sort.SliceStable(notification.Variables, func(i, j int) bool { return notification.Variables[i].ID < notification.Variables[j].ID }) sort.SliceStable(notification.Entries, func(i, j int) bool { return notification.Entries[i].ID < notification.Entries[j].ID }) macros := make([]string, 0, len(defaultNotificationMacroKeys)+len(notification.Variables)) for _, key := range defaultNotificationMacroKeys { macros = append(macros, "{{"+key+"}}") } for _, variable := range notification.Variables { macros = append(macros, "{{"+variable.Key+"}}") } notification.Macros = macros for i := range notification.Entries { sort.SliceStable(notification.Entries[i].Variables, func(left, right int) bool { return notification.Entries[i].Variables[left].ID < notification.Entries[i].Variables[right].ID }) for j := range notification.Entries[i].Variables { if notification.Entries[i].Variables[j].Variable != nil { notification.Entries[i].Variables[j].Key = notification.Entries[i].Variables[j].Variable.Key } } } }