diff --git a/local/controller/leaderboard.go b/local/controller/leaderboard.go index 1e1c9ba..d985fd9 100644 --- a/local/controller/leaderboard.go +++ b/local/controller/leaderboard.go @@ -55,7 +55,7 @@ func (lc *LeaderboardController) Update(c *fiber.Ctx) error { return lc.errorHandler.HandleUUIDError(c, "server ID") } - var input model.LeaderboardInput + var input model.Leaderboard if err := c.BodyParser(&input); err != nil { return lc.errorHandler.HandleParsingError(c, err) } diff --git a/local/model/leaderboard.go b/local/model/leaderboard.go index 6fabcff..5078008 100644 --- a/local/model/leaderboard.go +++ b/local/model/leaderboard.go @@ -1,10 +1,36 @@ package model import ( + "encoding/json" "github.com/google/uuid" "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 { ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` 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"` FLTextColor string `gorm:"default:'#000000'" json:"flTextColor"` Drivers []LeaderboardDriver `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"drivers"` - Races []LeaderboardRace `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"races"` - PointRows []LeaderboardPointRow `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"pointRows"` + Races []LeaderboardRace `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"tracks"` + PointRows []LeaderboardPointRow `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"pointsTable"` } type LeaderboardDriver struct { 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"` Initials string `json:"initials"` Color string `json:"color"` - Position int `json:"position"` + Position int `json:"-"` } type LeaderboardRace struct { 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"` - Position int `json:"position"` - FastestLapInitials string `json:"fastestLapInitials"` + Position int `json:"-"` + FastestLapDriverID *uuid.UUID `gorm:"type:uuid" json:"fastestLapDriverId"` Results []LeaderboardResult `gorm:"foreignKey:RaceID;constraint:OnDelete:CASCADE" json:"results"` } type LeaderboardResult struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` - RaceID uuid.UUID `gorm:"not null;type:uuid" json:"raceId"` + ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"-"` + RaceID uuid.UUID `gorm:"not null;type:uuid" json:"-"` DriverID uuid.UUID `gorm:"not null;type:uuid" json:"driverId"` - Score string `json:"score"` + Score Score `json:"score"` } type LeaderboardPointRow struct { 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"` Points int `json:"points"` Color string `json:"color"` @@ -51,42 +77,6 @@ type LeaderboardPointRow struct { 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 { if l.ID == uuid.Nil { l.ID = uuid.New() diff --git a/local/repository/leaderboard.go b/local/repository/leaderboard.go index a9150bf..8f67b61 100644 --- a/local/repository/leaderboard.go +++ b/local/repository/leaderboard.go @@ -92,7 +92,6 @@ func (r *LeaderboardRepository) FullReplace(ctx context.Context, serverID uuid.U // Re-insert children with correct leaderboard ID for i := range lb.Drivers { lb.Drivers[i].LeaderboardID = existing.ID - lb.Drivers[i].ID = uuid.Nil // force new ID } if len(lb.Drivers) > 0 { 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 { lb.Races[i].LeaderboardID = existing.ID - lb.Races[i].ID = uuid.Nil } if len(lb.Races) > 0 { // Create races first (without results) to get IDs diff --git a/local/service/leaderboard.go b/local/service/leaderboard.go index db15df9..64cdae0 100644 --- a/local/service/leaderboard.go +++ b/local/service/leaderboard.go @@ -17,189 +17,21 @@ func NewLeaderboardService(repo *repository.LeaderboardRepository) *LeaderboardS return &LeaderboardService{repo: repo} } -// Get returns the leaderboard for a server in the wire format (LeaderboardInput). -func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*model.LeaderboardInput, error) { - lb, err := s.repo.GetOrCreateByServerID(ctx, serverID) - if err != nil { - return nil, err - } - return toInput(lb), nil +// Get returns the leaderboard for a server. +func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*model.Leaderboard, error) { + return s.repo.GetOrCreateByServerID(ctx, serverID) } -// Update replaces the leaderboard for a server from wire format input. -func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, input *model.LeaderboardInput) (*model.LeaderboardInput, error) { - lb, err := fromInput(serverID, input) - 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 { +// Update replaces the leaderboard for a server. +func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard) (*model.Leaderboard, error) { + if lb == nil { return nil, fmt.Errorf("input is required") } - - lb := &model.Leaderboard{ - ServerID: serverID, - FLPoints: input.FLPoints.Points, - FLColor: input.FLPoints.Color, - FLTextColor: input.FLPoints.TextColor, + for i := range lb.Drivers { + lb.Drivers[i].Position = i } - - // Build drivers - 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, - } + for i := range lb.Races { + lb.Races[i].Position = i } - lb.Drivers = drivers - - // 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 + return s.repo.FullReplace(ctx, serverID, lb) }