Compare commits

...

5 Commits

Author SHA1 Message Date
c9a32e8889 feat: batch update config endpoint 2026-03-22 12:16:40 +01:00
7d470f3638 update headers 2026-03-05 21:50:05 +01:00
cf7473b373 update models to use uuid referencing
Some checks failed
Release and Deploy / build (push) Failing after 6m58s
Release and Deploy / deploy (push) Has been skipped
2026-03-01 16:46:06 +01:00
35c53cc54c public endpoint for leaderboard 2026-03-01 14:35:02 +01:00
016824e846 Merge branch 'feature/leaderboard'
Some checks failed
Release and Deploy / build (push) Failing after 7m6s
Release and Deploy / deploy (push) Has been skipped
2026-03-01 12:18:01 +01:00
8 changed files with 167 additions and 230 deletions

View File

@@ -2,6 +2,7 @@ package controller
import ( import (
"acc-server-manager/local/middleware" "acc-server-manager/local/middleware"
"acc-server-manager/local/model"
"acc-server-manager/local/service" "acc-server-manager/local/service"
"acc-server-manager/local/utl/common" "acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/error_handler" "acc-server-manager/local/utl/error_handler"
@@ -34,6 +35,7 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
configGroup := routeGroups.Config configGroup := routeGroups.Config
configGroup.Use(auth.Authenticate) configGroup.Use(auth.Authenticate)
configGroup.Patch("/", ac.UpdateAllConfigs)
configGroup.Put("/:file", ac.UpdateConfig) configGroup.Put("/:file", ac.UpdateConfig)
configGroup.Get("/:file", ac.GetConfig) configGroup.Get("/:file", ac.GetConfig)
configGroup.Get("/", ac.GetConfigs) configGroup.Get("/", ac.GetConfigs)
@@ -41,6 +43,54 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
return ac return ac
} }
// updateAllConfigs updates all configuration files
//
// @Summary Bulk update all server configuration files
// @Description Update multiple ACC server configuration files in one request. Null or omitted sections are skipped.
// @Tags Server Configuration
// @Accept json
// @Produce json
// @Param id path string true "Server ID (UUID format)"
// @Param restart query bool false "Restart the server after update"
// @Param override query bool false "Override instead of merging with existing config"
// @Param content body model.ConfigurationsUpdateRequest true "Configuration sections to update (null sections are skipped)"
// @Success 200 {array} model.Config "List of updated config audit records"
// @Failure 400 {object} error_handler.ErrorResponse "Invalid request or JSON format"
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
// @Security BearerAuth
// @Router /server/{id}/config [patch]
func (ac *ConfigController) UpdateAllConfigs(c *fiber.Ctx) error {
restart := c.QueryBool("restart")
serverID := c.Params("id")
if _, err := uuid.Parse(serverID); err != nil {
return ac.errorHandler.HandleUUIDError(c, "server ID")
}
c.Locals("serverId", serverID)
var req model.ConfigurationsUpdateRequest
if err := c.BodyParser(&req); err != nil {
return ac.errorHandler.HandleParsingError(c, err)
}
configs, err := ac.service.UpdateAllConfigs(c, &req)
if err != nil {
return ac.errorHandler.HandleServiceError(c, err)
}
if restart {
if _, err := ac.apiService.ServiceControlRestartServer(c); err != nil {
logging.ErrorWithContext("CONFIG_RESTART", "Failed to restart server after bulk config update: %v", err)
}
}
return c.JSON(configs)
}
// updateConfig returns Config // updateConfig returns Config
// //
// @Summary Update server configuration file // @Summary Update server configuration file

View File

@@ -22,6 +22,9 @@ func NewLeaderboardController(ls *service.LeaderboardService, routeGroups *commo
errorHandler: error_handler.NewControllerErrorHandler(), errorHandler: error_handler.NewControllerErrorHandler(),
} }
apiServerRoutes := routeGroups.Api.Group("/server/:id")
apiServerRoutes.Get("/leaderboard", lc.Get)
routeGroups.Leaderboard.Get("/", lc.Get) routeGroups.Leaderboard.Get("/", lc.Get)
routeGroups.Leaderboard.Put("/", auth.Authenticate, lc.Update) routeGroups.Leaderboard.Put("/", auth.Authenticate, lc.Update)
@@ -52,7 +55,7 @@ func (lc *LeaderboardController) Update(c *fiber.Ctx) error {
return lc.errorHandler.HandleUUIDError(c, "server ID") return lc.errorHandler.HandleUUIDError(c, "server ID")
} }
var input model.LeaderboardInput var input model.Leaderboard
if err := c.BodyParser(&input); err != nil { if err := c.BodyParser(&input); err != nil {
return lc.errorHandler.HandleParsingError(c, err) return lc.errorHandler.HandleParsingError(c, err)
} }

View File

@@ -40,6 +40,14 @@ type Configurations struct {
Settings ServerSettings `json:"settings"` Settings ServerSettings `json:"settings"`
} }
type ConfigurationsUpdateRequest struct {
Configuration *map[string]interface{} `json:"configuration"`
AssistRules *map[string]interface{} `json:"assistRules"`
Event *map[string]interface{} `json:"event"`
EventRules *map[string]interface{} `json:"eventRules"`
Settings *map[string]interface{} `json:"settings"`
}
type ServerSettings struct { type ServerSettings struct {
ServerName string `json:"serverName"` ServerName string `json:"serverName"`
AdminPassword string `json:"adminPassword"` AdminPassword string `json:"adminPassword"`

View File

@@ -1,10 +1,36 @@
package model package model
import ( import (
"encoding/json"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"strconv"
) )
// Score is a custom type that accepts both numbers and strings in JSON.
type Score string
func (s *Score) UnmarshalJSON(data []byte) error {
var n json.Number
if err := json.Unmarshal(data, &n); err == nil {
*s = Score(n.String())
return nil
}
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
*s = Score(str)
return nil
}
func (s Score) MarshalJSON() ([]byte, error) {
if n, err := strconv.Atoi(string(s)); err == nil {
return json.Marshal(n)
}
return json.Marshal(string(s))
}
type Leaderboard struct { type Leaderboard struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
ServerID uuid.UUID `gorm:"uniqueIndex;not null;type:uuid" json:"serverId"` ServerID uuid.UUID `gorm:"uniqueIndex;not null;type:uuid" json:"serverId"`
@@ -12,38 +38,38 @@ type Leaderboard struct {
FLColor string `gorm:"default:'#8b5cf6'" json:"flColor"` FLColor string `gorm:"default:'#8b5cf6'" json:"flColor"`
FLTextColor string `gorm:"default:'#000000'" json:"flTextColor"` FLTextColor string `gorm:"default:'#000000'" json:"flTextColor"`
Drivers []LeaderboardDriver `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"drivers"` Drivers []LeaderboardDriver `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"drivers"`
Races []LeaderboardRace `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"races"` Races []LeaderboardRace `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"tracks"`
PointRows []LeaderboardPointRow `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"pointRows"` PointRows []LeaderboardPointRow `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"pointsTable"`
} }
type LeaderboardDriver struct { type LeaderboardDriver struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"leaderboardId"` LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Initials string `json:"initials"` Initials string `json:"initials"`
Color string `json:"color"` Color string `json:"color"`
Position int `json:"position"` Position int `json:"-"`
} }
type LeaderboardRace struct { type LeaderboardRace struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"leaderboardId"` LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Position int `json:"position"` Position int `json:"-"`
FastestLapInitials string `json:"fastestLapInitials"` FastestLapDriverID *uuid.UUID `gorm:"type:uuid" json:"fastestLapDriverId"`
Results []LeaderboardResult `gorm:"foreignKey:RaceID;constraint:OnDelete:CASCADE" json:"results"` Results []LeaderboardResult `gorm:"foreignKey:RaceID;constraint:OnDelete:CASCADE" json:"results"`
} }
type LeaderboardResult struct { type LeaderboardResult struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"-"`
RaceID uuid.UUID `gorm:"not null;type:uuid" json:"raceId"` RaceID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
DriverID uuid.UUID `gorm:"not null;type:uuid" json:"driverId"` DriverID uuid.UUID `gorm:"not null;type:uuid" json:"driverId"`
Score string `json:"score"` Score Score `json:"score"`
} }
type LeaderboardPointRow struct { type LeaderboardPointRow struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"leaderboardId"` LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
Label string `json:"label"` Label string `json:"label"`
Points int `json:"points"` Points int `json:"points"`
Color string `json:"color"` Color string `json:"color"`
@@ -51,42 +77,6 @@ type LeaderboardPointRow struct {
Priority int `json:"priority"` Priority int `json:"priority"`
} }
// LeaderboardInput is the wire format matching the HTML's appData shape.
type LeaderboardInput struct {
Drivers []LeaderboardDriverInput `json:"drivers"`
PointsTable []LeaderboardPointRowInput `json:"pointsTable"`
FLPoints LeaderboardFLInput `json:"flPoints"`
Tracks []LeaderboardTrackInput `json:"tracks"`
}
type LeaderboardDriverInput struct {
Name string `json:"name"`
Color string `json:"color"`
Initials string `json:"initials"`
}
type LeaderboardPointRowInput struct {
Points int `json:"points"`
Label string `json:"label"`
Color string `json:"color"`
TextColor string `json:"textColor"`
Priority int `json:"priority"`
}
type LeaderboardFLInput struct {
Points int `json:"points"`
Label string `json:"label"`
Color string `json:"color"`
TextColor string `json:"textColor"`
Priority int `json:"priority"`
}
type LeaderboardTrackInput struct {
Name string `json:"name"`
Results []interface{} `json:"results"`
FastestLapInitials string `json:"fastestLapInitials"`
}
func (l *Leaderboard) BeforeCreate(tx *gorm.DB) error { func (l *Leaderboard) BeforeCreate(tx *gorm.DB) error {
if l.ID == uuid.Nil { if l.ID == uuid.Nil {
l.ID = uuid.New() l.ID = uuid.New()

View File

@@ -92,7 +92,6 @@ func (r *LeaderboardRepository) FullReplace(ctx context.Context, serverID uuid.U
// Re-insert children with correct leaderboard ID // Re-insert children with correct leaderboard ID
for i := range lb.Drivers { for i := range lb.Drivers {
lb.Drivers[i].LeaderboardID = existing.ID lb.Drivers[i].LeaderboardID = existing.ID
lb.Drivers[i].ID = uuid.Nil // force new ID
} }
if len(lb.Drivers) > 0 { if len(lb.Drivers) > 0 {
if err := tx.Create(&lb.Drivers).Error; err != nil { if err := tx.Create(&lb.Drivers).Error; err != nil {
@@ -112,7 +111,6 @@ func (r *LeaderboardRepository) FullReplace(ctx context.Context, serverID uuid.U
for i := range lb.Races { for i := range lb.Races {
lb.Races[i].LeaderboardID = existing.ID lb.Races[i].LeaderboardID = existing.ID
lb.Races[i].ID = uuid.Nil
} }
if len(lb.Races) > 0 { if len(lb.Races) > 0 {
// Create races first (without results) to get IDs // Create races first (without results) to get IDs

View File

@@ -91,6 +91,62 @@ func (as *ConfigService) SetServerService(serverService *ServerService) {
// context.Context: Application context // context.Context: Application context
// Returns: // Returns:
// string: Application version // string: Application version
func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.ConfigurationsUpdateRequest) ([]*model.Config, error) {
serverID := ctx.Locals("serverId").(string)
override := ctx.QueryBool("override", false)
serverUUID, err := uuid.Parse(serverID)
if err != nil {
logging.Error("Invalid server ID format: %v", err)
return nil, fmt.Errorf("invalid server ID format")
}
server, err := as.serverRepository.GetByID(ctx.UserContext(), serverUUID)
if err != nil {
logging.Error("Server not found")
return nil, fmt.Errorf("server not found")
}
type configEntry struct {
fileName string
body *map[string]interface{}
}
entries := []configEntry{
{ConfigurationJson, req.Configuration},
{AssistRulesJson, req.AssistRules},
{EventJson, req.Event},
{EventRulesJson, req.EventRules},
{SettingsJson, req.Settings},
}
var results []*model.Config
for _, entry := range entries {
if entry.body == nil {
continue
}
oldDataUTF8, newData, err := as.updateConfigFiles(ctx.UserContext(), server, entry.fileName, entry.body, override)
if err != nil {
return nil, err
}
result := as.repository.UpdateConfig(ctx.UserContext(), &model.Config{
ServerID: serverUUID,
ConfigFile: entry.fileName,
OldConfig: string(oldDataUTF8),
NewConfig: string(newData),
ChangedAt: time.Now(),
})
results = append(results, result)
}
if len(results) > 0 {
as.configCache.InvalidateServerCache(serverID)
as.serverService.StartAccServerRuntime(server)
}
return results, nil
}
func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{}) (*model.Config, error) { func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{}) (*model.Config, error) {
serverID := ctx.Locals("serverId").(string) serverID := ctx.Locals("serverId").(string)
configFile := ctx.Params("file") configFile := ctx.Params("file")

View File

@@ -17,189 +17,21 @@ func NewLeaderboardService(repo *repository.LeaderboardRepository) *LeaderboardS
return &LeaderboardService{repo: repo} return &LeaderboardService{repo: repo}
} }
// Get returns the leaderboard for a server in the wire format (LeaderboardInput). // Get returns the leaderboard for a server.
func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*model.LeaderboardInput, error) { func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*model.Leaderboard, error) {
lb, err := s.repo.GetOrCreateByServerID(ctx, serverID) return s.repo.GetOrCreateByServerID(ctx, serverID)
if err != nil {
return nil, err
}
return toInput(lb), nil
} }
// Update replaces the leaderboard for a server from wire format input. // Update replaces the leaderboard for a server.
func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, input *model.LeaderboardInput) (*model.LeaderboardInput, error) { func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard) (*model.Leaderboard, error) {
lb, err := fromInput(serverID, input) if lb == nil {
if err != nil {
return nil, err
}
result, err := s.repo.FullReplace(ctx, serverID, lb)
if err != nil {
return nil, err
}
return toInput(result), nil
}
// toInput converts the normalized DB model to the flat wire format.
func toInput(lb *model.Leaderboard) *model.LeaderboardInput {
// Build driver map (id -> index in slice) for result ordering
driverIndexByID := make(map[uuid.UUID]int, len(lb.Drivers))
// Sort drivers by position
drivers := make([]model.LeaderboardDriverInput, len(lb.Drivers))
for i, d := range lb.Drivers {
drivers[i] = model.LeaderboardDriverInput{
Name: d.Name,
Color: d.Color,
Initials: d.Initials,
}
driverIndexByID[d.ID] = d.Position
}
// Re-index by position order
driverByPos := make(map[int]model.LeaderboardDriver, len(lb.Drivers))
for _, d := range lb.Drivers {
driverByPos[d.Position] = d
}
driverCount := len(lb.Drivers)
orderedDrivers := make([]model.LeaderboardDriver, driverCount)
for i := 0; i < driverCount; i++ {
if d, ok := driverByPos[i]; ok {
orderedDrivers[i] = d
drivers[i] = model.LeaderboardDriverInput{
Name: d.Name,
Color: d.Color,
Initials: d.Initials,
}
driverIndexByID[d.ID] = i
}
}
// Build tracks
raceByPos := make(map[int]model.LeaderboardRace, len(lb.Races))
for _, r := range lb.Races {
raceByPos[r.Position] = r
}
tracks := make([]model.LeaderboardTrackInput, len(lb.Races))
for i := 0; i < len(lb.Races); i++ {
race, ok := raceByPos[i]
if !ok {
continue
}
results := make([]interface{}, driverCount)
for j := range results {
results[j] = "0"
}
for _, res := range race.Results {
idx, found := driverIndexByID[res.DriverID]
if found && idx < driverCount {
results[idx] = res.Score
}
}
tracks[i] = model.LeaderboardTrackInput{
Name: race.Name,
Results: results,
FastestLapInitials: race.FastestLapInitials,
}
}
// Build points table
pointsTable := make([]model.LeaderboardPointRowInput, len(lb.PointRows))
for i, p := range lb.PointRows {
pointsTable[i] = model.LeaderboardPointRowInput{
Points: p.Points,
Label: p.Label,
Color: p.Color,
TextColor: p.TextColor,
Priority: p.Priority,
}
}
flColor := lb.FLColor
if flColor == "" {
flColor = "#8b5cf6"
}
flTextColor := lb.FLTextColor
if flTextColor == "" {
flTextColor = "#000000"
}
return &model.LeaderboardInput{
Drivers: drivers,
PointsTable: pointsTable,
FLPoints: model.LeaderboardFLInput{
Points: lb.FLPoints,
Label: "FL +1",
Color: flColor,
TextColor: flTextColor,
Priority: len(pointsTable) + 1,
},
Tracks: tracks,
}
}
// fromInput converts the wire format to the normalized DB model.
func fromInput(serverID uuid.UUID, input *model.LeaderboardInput) (*model.Leaderboard, error) {
if input == nil {
return nil, fmt.Errorf("input is required") return nil, fmt.Errorf("input is required")
} }
for i := range lb.Drivers {
lb := &model.Leaderboard{ lb.Drivers[i].Position = i
ServerID: serverID,
FLPoints: input.FLPoints.Points,
FLColor: input.FLPoints.Color,
FLTextColor: input.FLPoints.TextColor,
} }
for i := range lb.Races {
// Build drivers lb.Races[i].Position = i
drivers := make([]model.LeaderboardDriver, len(input.Drivers))
driverIDs := make([]uuid.UUID, len(input.Drivers))
for i, d := range input.Drivers {
id := uuid.New()
driverIDs[i] = id
drivers[i] = model.LeaderboardDriver{
ID: id,
Name: d.Name,
Initials: d.Initials,
Color: d.Color,
Position: i,
}
} }
lb.Drivers = drivers return s.repo.FullReplace(ctx, serverID, lb)
// Build point rows
pointRows := make([]model.LeaderboardPointRow, len(input.PointsTable))
for i, p := range input.PointsTable {
pointRows[i] = model.LeaderboardPointRow{
Label: p.Label,
Points: p.Points,
Color: p.Color,
TextColor: p.TextColor,
Priority: p.Priority,
}
}
lb.PointRows = pointRows
// Build races
races := make([]model.LeaderboardRace, len(input.Tracks))
for i, t := range input.Tracks {
results := make([]model.LeaderboardResult, 0, len(t.Results))
for j, score := range t.Results {
if j >= len(driverIDs) {
break
}
scoreStr := fmt.Sprintf("%v", score)
results = append(results, model.LeaderboardResult{
DriverID: driverIDs[j],
Score: scoreStr,
})
}
races[i] = model.LeaderboardRace{
Name: t.Name,
Position: i,
FastestLapInitials: t.FastestLapInitials,
Results: results,
}
}
lb.Races = races
return lb, nil
} }

2
local/utl/server/server.go Normal file → Executable file
View File

@@ -44,7 +44,7 @@ func Start(di *dig.Container) *fiber.App {
app.Use(cors.New(cors.Config{ app.Use(cors.New(cors.Config{
AllowOrigins: allowedOrigin, AllowOrigins: allowedOrigin,
AllowHeaders: "Origin, Content-Type, Accept, Authorization", AllowHeaders: "Origin, Content-Type, Accept, Authorization, Access-Token",
AllowMethods: "GET, POST, PUT, DELETE, OPTIONS", AllowMethods: "GET, POST, PUT, DELETE, OPTIONS",
AllowCredentials: true, AllowCredentials: true,
MaxAge: 86400, // 24 hours MaxAge: 86400, // 24 hours