cab-backend/internal/handlers/notifications.go
2026-03-30 21:00:35 +03:00

682 lines
20 KiB
Go

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(&notifications).
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(&notifications[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
}
}
}
}