From 190e1a3a16069c40776f924787085307791cad76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Sun, 22 Feb 2026 12:28:48 +0100 Subject: [PATCH 1/2] add leaderboard --- local/api/api.go | 1 + local/controller/controller.go | 5 + local/controller/leaderboard.go | 66 ++++++++++ local/model/leaderboard.go | 123 +++++++++++++++++++ local/repository/leaderboard.go | 146 +++++++++++++++++++++++ local/repository/repository.go | 1 + local/service/leaderboard.go | 205 ++++++++++++++++++++++++++++++++ local/service/service.go | 1 + local/utl/common/common.go | 1 + local/utl/db/db.go | 5 + 10 files changed, 554 insertions(+) create mode 100644 local/controller/leaderboard.go create mode 100644 local/model/leaderboard.go create mode 100644 local/repository/leaderboard.go create mode 100644 local/service/leaderboard.go diff --git a/local/api/api.go b/local/api/api.go index 61dba0c..f8b6114 100644 --- a/local/api/api.go +++ b/local/api/api.go @@ -32,6 +32,7 @@ func Init(di *dig.Container, app *fiber.App) { Membership: groups.Group("/membership"), System: groups.Group("/system"), WebSocket: groups.Group("/ws"), + Leaderboard: serverIdGroup.Group("/leaderboard"), } accessKeyMiddleware := middleware.NewAccessKeyMiddleware() diff --git a/local/controller/controller.go b/local/controller/controller.go index 583b033..96c5692 100644 --- a/local/controller/controller.go +++ b/local/controller/controller.go @@ -59,4 +59,9 @@ func InitializeControllers(c *dig.Container) { if err != nil { logging.Panic("unable to initialize websocket controller") } + + err = c.Invoke(NewLeaderboardController) + if err != nil { + logging.Panic("unable to initialize leaderboard controller") + } } diff --git a/local/controller/leaderboard.go b/local/controller/leaderboard.go new file mode 100644 index 0000000..b1ce26a --- /dev/null +++ b/local/controller/leaderboard.go @@ -0,0 +1,66 @@ +package controller + +import ( + "acc-server-manager/local/middleware" + "acc-server-manager/local/model" + "acc-server-manager/local/service" + "acc-server-manager/local/utl/common" + "acc-server-manager/local/utl/error_handler" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type LeaderboardController struct { + service *service.LeaderboardService + errorHandler *error_handler.ControllerErrorHandler +} + +func NewLeaderboardController(ls *service.LeaderboardService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *LeaderboardController { + lc := &LeaderboardController{ + service: ls, + errorHandler: error_handler.NewControllerErrorHandler(), + } + + routeGroups.Leaderboard.Get("/", lc.Get) + routeGroups.Leaderboard.Put("/", auth.Authenticate, lc.Update) + + return lc +} + +// Get returns the leaderboard for a server (public, no auth required). +func (lc *LeaderboardController) Get(c *fiber.Ctx) error { + serverIDStr := c.Params("id") + serverID, err := uuid.Parse(serverIDStr) + if err != nil { + return lc.errorHandler.HandleUUIDError(c, "server ID") + } + + data, err := lc.service.Get(c.UserContext(), serverID) + if err != nil { + return lc.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(data) +} + +// Update replaces the leaderboard for a server (requires JWT auth). +func (lc *LeaderboardController) Update(c *fiber.Ctx) error { + serverIDStr := c.Params("id") + serverID, err := uuid.Parse(serverIDStr) + if err != nil { + return lc.errorHandler.HandleUUIDError(c, "server ID") + } + + var input model.LeaderboardInput + if err := c.BodyParser(&input); err != nil { + return lc.errorHandler.HandleParsingError(c, err) + } + + data, err := lc.service.Update(c.UserContext(), serverID, &input) + if err != nil { + return lc.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(data) +} diff --git a/local/model/leaderboard.go b/local/model/leaderboard.go new file mode 100644 index 0000000..6fabcff --- /dev/null +++ b/local/model/leaderboard.go @@ -0,0 +1,123 @@ +package model + +import ( + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Leaderboard struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + ServerID uuid.UUID `gorm:"uniqueIndex;not null;type:uuid" json:"serverId"` + FLPoints int `gorm:"default:1" json:"flPoints"` + 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"` +} + +type LeaderboardDriver struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"leaderboardId"` + Name string `gorm:"not null" json:"name"` + Initials string `json:"initials"` + Color string `json:"color"` + Position int `json:"position"` +} + +type LeaderboardRace struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"leaderboardId"` + Name string `gorm:"not null" json:"name"` + Position int `json:"position"` + FastestLapInitials string `json:"fastestLapInitials"` + 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"` + DriverID uuid.UUID `gorm:"not null;type:uuid" json:"driverId"` + Score string `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"` + Label string `json:"label"` + Points int `json:"points"` + Color string `json:"color"` + TextColor string `json:"textColor"` + 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() + } + return nil +} + +func (d *LeaderboardDriver) BeforeCreate(tx *gorm.DB) error { + if d.ID == uuid.Nil { + d.ID = uuid.New() + } + return nil +} + +func (r *LeaderboardRace) BeforeCreate(tx *gorm.DB) error { + if r.ID == uuid.Nil { + r.ID = uuid.New() + } + return nil +} + +func (r *LeaderboardResult) BeforeCreate(tx *gorm.DB) error { + if r.ID == uuid.Nil { + r.ID = uuid.New() + } + return nil +} + +func (p *LeaderboardPointRow) BeforeCreate(tx *gorm.DB) error { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + return nil +} diff --git a/local/repository/leaderboard.go b/local/repository/leaderboard.go new file mode 100644 index 0000000..a9150bf --- /dev/null +++ b/local/repository/leaderboard.go @@ -0,0 +1,146 @@ +package repository + +import ( + "acc-server-manager/local/model" + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type LeaderboardRepository struct { + db *gorm.DB +} + +func NewLeaderboardRepository(db *gorm.DB) *LeaderboardRepository { + return &LeaderboardRepository{db: db} +} + +// GetOrCreateByServerID fetches the leaderboard for a server, creating an empty one if it doesn't exist. +func (r *LeaderboardRepository) GetOrCreateByServerID(ctx context.Context, serverID uuid.UUID) (*model.Leaderboard, error) { + lb := new(model.Leaderboard) + err := r.db.WithContext(ctx). + Preload("Drivers"). + Preload("Races.Results"). + Preload("PointRows"). + Where("server_id = ?", serverID). + First(lb).Error + + if err == nil { + return lb, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("error fetching leaderboard: %w", err) + } + + lb = &model.Leaderboard{ + ServerID: serverID, + FLPoints: 1, + FLColor: "#8b5cf6", + FLTextColor: "#000000", + } + if err := r.db.WithContext(ctx).Create(lb).Error; err != nil { + return nil, fmt.Errorf("error creating leaderboard: %w", err) + } + return lb, nil +} + +// FullReplace replaces all leaderboard data for a server in a single transaction. +func (r *LeaderboardRepository) FullReplace(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard) (*model.Leaderboard, error) { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + existing := new(model.Leaderboard) + err := tx.Where("server_id = ?", serverID).First(existing).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("error fetching existing leaderboard: %w", err) + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + lb.ServerID = serverID + return tx.Create(lb).Error + } + + // Delete children + if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardPointRow{}).Error; err != nil { + return err + } + if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardDriver{}).Error; err != nil { + return err + } + + // Get race IDs to delete results + var raceIDs []uuid.UUID + tx.Model(&model.LeaderboardRace{}).Where("leaderboard_id = ?", existing.ID).Pluck("id", &raceIDs) + if len(raceIDs) > 0 { + if err := tx.Where("race_id IN ?", raceIDs).Delete(&model.LeaderboardResult{}).Error; err != nil { + return err + } + } + if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardRace{}).Error; err != nil { + return err + } + + // Update parent fields + existing.FLPoints = lb.FLPoints + existing.FLColor = lb.FLColor + existing.FLTextColor = lb.FLTextColor + if err := tx.Save(existing).Error; err != nil { + return err + } + + // 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 { + return err + } + } + + for i := range lb.PointRows { + lb.PointRows[i].LeaderboardID = existing.ID + lb.PointRows[i].ID = uuid.Nil + } + if len(lb.PointRows) > 0 { + if err := tx.Create(&lb.PointRows).Error; err != nil { + return err + } + } + + 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 + for i := range lb.Races { + results := lb.Races[i].Results + lb.Races[i].Results = nil + if err := tx.Create(&lb.Races[i]).Error; err != nil { + return err + } + // Re-attach results with correct race ID + for j := range results { + results[j].RaceID = lb.Races[i].ID + results[j].ID = uuid.Nil + } + if len(results) > 0 { + if err := tx.Create(&results).Error; err != nil { + return err + } + } + } + } + + lb.ID = existing.ID + return nil + }) + if err != nil { + return nil, err + } + + return r.GetOrCreateByServerID(ctx, serverID) +} diff --git a/local/repository/repository.go b/local/repository/repository.go index 8600daf..c8feb17 100644 --- a/local/repository/repository.go +++ b/local/repository/repository.go @@ -19,6 +19,7 @@ func InitializeRepositories(c *dig.Container) { c.Provide(NewLookupRepository) c.Provide(NewSteamCredentialsRepository) c.Provide(NewMembershipRepository) + c.Provide(NewLeaderboardRepository) if err := c.Provide(func() *model.Steam2FAManager { manager := model.NewSteam2FAManager() diff --git a/local/service/leaderboard.go b/local/service/leaderboard.go new file mode 100644 index 0000000..db15df9 --- /dev/null +++ b/local/service/leaderboard.go @@ -0,0 +1,205 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "context" + "fmt" + + "github.com/google/uuid" +) + +type LeaderboardService struct { + repo *repository.LeaderboardRepository +} + +func NewLeaderboardService(repo *repository.LeaderboardRepository) *LeaderboardService { + 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 +} + +// 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 { + return nil, fmt.Errorf("input is required") + } + + lb := &model.Leaderboard{ + ServerID: serverID, + FLPoints: input.FLPoints.Points, + FLColor: input.FLPoints.Color, + FLTextColor: input.FLPoints.TextColor, + } + + // 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, + } + } + 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 +} diff --git a/local/service/service.go b/local/service/service.go index 46df608..64f3e9e 100644 --- a/local/service/service.go +++ b/local/service/service.go @@ -23,6 +23,7 @@ func InitializeServices(c *dig.Container) { c.Provide(NewFirewallService) c.Provide(NewMembershipService) c.Provide(NewWebSocketService) + c.Provide(NewLeaderboardService) logging.Debug("Initializing service dependencies") err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) { diff --git a/local/utl/common/common.go b/local/utl/common/common.go index 4757b59..8e94bbe 100644 --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -26,6 +26,7 @@ type RouteGroups struct { Membership fiber.Router System fiber.Router WebSocket fiber.Router + Leaderboard fiber.Router } func CheckError(err error) { diff --git a/local/utl/db/db.go b/local/utl/db/db.go index 7efa670..85c80e3 100644 --- a/local/utl/db/db.go +++ b/local/utl/db/db.go @@ -47,6 +47,11 @@ func Migrate(db *gorm.DB) { &model.User{}, &model.Role{}, &model.Permission{}, + &model.Leaderboard{}, + &model.LeaderboardDriver{}, + &model.LeaderboardRace{}, + &model.LeaderboardResult{}, + &model.LeaderboardPointRow{}, ) if err != nil { From d62c8cef34c1c38e198532c805b49b9fe3b1996f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Sun, 22 Feb 2026 17:49:44 +0100 Subject: [PATCH 2/2] add build scripts and resolve migration --- local/migrations/004_fix_ddl_comments.go | 106 +++++++++++++++++++++++ local/utl/db/db.go | 6 ++ scripts/build.ps1 | 57 ++++++++++++ scripts/build.sh | 79 +++++++++++++++++ scripts/deploy.ps1 | 42 ++------- 5 files changed, 257 insertions(+), 33 deletions(-) create mode 100644 local/migrations/004_fix_ddl_comments.go create mode 100644 scripts/build.ps1 create mode 100755 scripts/build.sh diff --git a/local/migrations/004_fix_ddl_comments.go b/local/migrations/004_fix_ddl_comments.go new file mode 100644 index 0000000..f3ee372 --- /dev/null +++ b/local/migrations/004_fix_ddl_comments.go @@ -0,0 +1,106 @@ +package migrations + +import ( + "acc-server-manager/local/utl/logging" + "fmt" + "regexp" + "strings" + + "gorm.io/gorm" +) + +var sqlLineCommentRegexp = regexp.MustCompile(`--[^\n]*`) + +type Migration004FixDDLComments struct { + DB *gorm.DB +} + +func NewMigration004FixDDLComments(db *gorm.DB) *Migration004FixDDLComments { + return &Migration004FixDDLComments{DB: db} +} + +func (m *Migration004FixDDLComments) Up() error { + if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil { + return fmt.Errorf("failed to create migration tracking table: %v", err) + } + + var record MigrationRecord + if err := m.DB.Where("migration_name = ?", "004_fix_ddl_comments").First(&record).Error; err == nil { + logging.Info("DDL comment fix migration already applied, skipping") + return nil + } + + var tables []string + if err := m.DB.Raw( + "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE '%--%' ORDER BY name", + ).Scan(&tables).Error; err != nil { + return fmt.Errorf("failed to query tables with DDL comments: %v", err) + } + + if len(tables) == 0 { + logging.Info("No tables with DDL comments found, skipping") + } + + for _, table := range tables { + if err := m.rebuildTable(table); err != nil { + return fmt.Errorf("failed to rebuild table %q: %v", table, err) + } + } + + record = MigrationRecord{ + MigrationName: "004_fix_ddl_comments", + AppliedAt: "datetime('now')", + Success: true, + Notes: fmt.Sprintf("Rebuilt %d table(s): %s", len(tables), strings.Join(tables, ", ")), + } + if err := m.DB.Create(&record).Error; err != nil { + return fmt.Errorf("failed to record migration: %v", err) + } + + return nil +} + +func (m *Migration004FixDDLComments) rebuildTable(table string) error { + var originalDDL string + if err := m.DB.Raw( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", table, + ).Row().Scan(&originalDDL); err != nil { + return err + } + + cleanDDL := sqlLineCommentRegexp.ReplaceAllString(originalDDL, "") + + tmpTable := table + "__fixed" + createTmp := strings.Replace(cleanDDL, `"`+table+`"`, "`"+tmpTable+"`", 1) + if createTmp == cleanDDL { + createTmp = strings.Replace(cleanDDL, table, "`"+tmpTable+"`", 1) + } + + logging.Info("Rebuilding table %q to remove DDL comments...", table) + + return m.DB.Transaction(func(tx *gorm.DB) error { + for _, sql := range []string{ + "PRAGMA foreign_keys = OFF", + fmt.Sprintf("DROP TABLE IF EXISTS `%s`", tmpTable), + createTmp, + fmt.Sprintf("INSERT INTO `%s` SELECT * FROM `%s`", tmpTable, table), + fmt.Sprintf("DROP TABLE `%s`", table), + fmt.Sprintf("ALTER TABLE `%s` RENAME TO `%s`", tmpTable, table), + "PRAGMA foreign_keys = ON", + } { + if err := tx.Exec(sql).Error; err != nil { + return fmt.Errorf("exec %q: %w", sql, err) + } + } + logging.Info("Table %q rebuilt successfully", table) + return nil + }) +} + +func (m *Migration004FixDDLComments) Down() error { + return fmt.Errorf("DDL comment fix migration rollback is not supported") +} + +func RunFixDDLCommentsMigration(db *gorm.DB) error { + return NewMigration004FixDDLComments(db).Up() +} diff --git a/local/utl/db/db.go b/local/utl/db/db.go index 85c80e3..55ca071 100644 --- a/local/utl/db/db.go +++ b/local/utl/db/db.go @@ -33,6 +33,8 @@ func Start(di *dig.Container) { func Migrate(db *gorm.DB) { logging.Info("Migrating database") + runMigrations(db) + err := db.AutoMigrate( &model.ServiceControlModel{}, &model.Config{}, @@ -66,6 +68,10 @@ func Migrate(db *gorm.DB) { func runMigrations(db *gorm.DB) { logging.Info("Running custom database migrations...") + if err := migrations.RunFixDDLCommentsMigration(db); err != nil { + logging.Error("Failed to run DDL comment fix migration: %v", err) + } + if err := migrations.RunPasswordSecurityMigration(db); err != nil { logging.Error("Failed to run password security migration: %v", err) } diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..a83ad5f --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,57 @@ +param( + [string]$BinaryName = "acc-server-manager", + [string]$MigrateBinaryName = "acc-migrate", + [string]$GOOS = "windows", + [string]$GOARCH = "amd64", + [string]$OutputPath = ".\build", + [switch]$SkipTests +) + +$ErrorActionPreference = "Stop" + +function Write-Status { + param([string]$Message, [string]$Color = "Green") + Write-Host "[$(Get-Date -Format 'HH:mm:ss')] $Message" -ForegroundColor $Color +} + +# Check if Go is installed +if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "Go is not installed or not in PATH" +} + +# Clean output directory +if (Test-Path $OutputPath) { + Remove-Item -Path $OutputPath -Recurse -Force +} +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null + +# Run tests +if (-not $SkipTests) { + Write-Status "Running tests..." + & go test ./... + if ($LASTEXITCODE -ne 0) { + throw "Tests failed" + } +} + +# Set build environment +$env:GOOS = $GOOS +$env:GOARCH = $GOARCH + +$ext = if ($GOOS -eq "windows") { ".exe" } else { "" } + +# Build API binary +Write-Status "Building API binary ($GOOS/$GOARCH)..." +& go build -o "$OutputPath\$BinaryName$ext" .\cmd\api +if ($LASTEXITCODE -ne 0) { throw "Failed to build API binary" } + +# Build migration binary +Write-Status "Building migration binary ($GOOS/$GOARCH)..." +& go build -o "$OutputPath\$MigrateBinaryName$ext" .\cmd\migrate +if ($LASTEXITCODE -ne 0) { throw "Failed to build migration binary" } + +# Reset environment +Remove-Item Env:\GOOS -ErrorAction SilentlyContinue +Remove-Item Env:\GOARCH -ErrorAction SilentlyContinue + +Write-Status "Build completed -> $OutputPath" diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..f5306e2 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +BINARY_NAME="acc-server-manager" +MIGRATE_BINARY_NAME="acc-migrate" +GOOS="windows" +GOARCH="amd64" +OUTPUT_PATH="./build" +SKIP_TESTS=false + +usage() { + cat < Main binary name (default: acc-server-manager) + --migrate-binary-name Migration binary name (default: acc-migrate) + --goos Target OS (default: windows) + --goarch Target arch (default: amd64) + --output-path Output directory (default: ./build) + --skip-tests Skip running tests + -h, --help Show this help +EOF +} + +log() { echo "[$(date '+%H:%M:%S')] $*"; } +die() { echo "[$(date '+%H:%M:%S')] ERROR: $*" >&2; exit 1; } + +while [[ $# -gt 0 ]]; do + case $1 in + --binary-name) BINARY_NAME="$2"; shift 2 ;; + --migrate-binary-name) MIGRATE_BINARY_NAME="$2"; shift 2 ;; + --goos) GOOS="$2"; shift 2 ;; + --goarch) GOARCH="$2"; shift 2 ;; + --output-path) OUTPUT_PATH="$2"; shift 2 ;; + --skip-tests) SKIP_TESTS=true; shift ;; + -h|--help) usage; exit 0 ;; + *) die "Unknown option: $1" ;; + esac +done + +command -v go >/dev/null 2>&1 || die "Go is not installed or not in PATH" + +# Clean output directory +rm -rf "$OUTPUT_PATH" +mkdir -p "$OUTPUT_PATH" + +# Run tests on host before setting cross-compilation environment +if [[ "$SKIP_TESTS" == false ]]; then + log "Running tests..." + go test ./... +fi + +# CGo cross-compilation setup (set after tests so they run natively) +HOST_OS="$(go env GOHOSTOS)" +if [[ "$GOOS" == "windows" && "$HOST_OS" != "windows" ]]; then + command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 \ + || die "mingw-w64 not found. Install it with: sudo apt install gcc-mingw-w64-x86-64" + export CC=x86_64-w64-mingw32-gcc + export CGO_ENABLED=1 + log "Cross-compiling for Windows (CC=$CC)" +fi + +EXT="" +[[ "$GOOS" == "windows" ]] && EXT=".exe" + +export GOOS GOARCH + +# Build API binary +log "Building API binary ($GOOS/$GOARCH)..." +go build -o "$OUTPUT_PATH/$BINARY_NAME$EXT" ./cmd/api + +# Build migration binary +log "Building migration binary ($GOOS/$GOARCH)..." +go build -o "$OUTPUT_PATH/$MIGRATE_BINARY_NAME$EXT" ./cmd/migrate + +unset GOOS GOARCH CC CGO_ENABLED + +log "Build completed -> $OUTPUT_PATH" diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index ccc0652..bab30f7 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -61,42 +61,18 @@ function Build-Application { Write-Status "Building application..." - # Clean build directory - if (Test-Path $LocalBuildPath) { - Remove-Item -Path $LocalBuildPath -Recurse -Force - } - New-Item -ItemType Directory -Path $LocalBuildPath -Force | Out-Null + $buildScript = Join-Path $PSScriptRoot "build.ps1" + $buildArgs = @( + "-BinaryName", $BinaryName, + "-MigrateBinaryName", $MigrateBinaryName, + "-OutputPath", $LocalBuildPath + ) + if ($SkipTests) { $buildArgs += "-SkipTests" } - # Run tests - if (-not $SkipTests) { - Write-Status "Running tests..." - & go test -v ./... - if ($LASTEXITCODE -ne 0) { - throw "Tests failed" - } - } - - # Build API for Windows - Write-Status "Building API binary..." - $env:GOOS = "windows" - $env:GOARCH = "amd64" - & go build -o "$LocalBuildPath\$BinaryName.exe" .\cmd\api + & $buildScript @buildArgs if ($LASTEXITCODE -ne 0) { - throw "Failed to build API binary" + throw "Build failed" } - - # Build migration tool for Windows - Write-Status "Building migration binary..." - & go build -o "$LocalBuildPath\$MigrateBinaryName.exe" .\cmd\migrate - if ($LASTEXITCODE -ne 0) { - throw "Failed to build migration binary" - } - - # Reset environment variables - Remove-Item Env:\GOOS -ErrorAction SilentlyContinue - Remove-Item Env:\GOARCH -ErrorAction SilentlyContinue - - Write-Status "Build completed successfully" } function Copy-FilesToServer {